Manage controllers

Bootstrap a controller

To bootstrap a controller:

  1. Configure the provider with controller_mode = true. For example:

main.tf
terraform {
  required_providers {
    juju = {
      source  = "juju/juju"
      version = "~> 1.0.0"
    }
  }
}

provider "juju" {
  controller_mode = true
}

This enables bootstrapping and restricts resource creation to controllers only.

  1. Gather the necessary cloud credentials for your target cloud (e.g., LXD, AWS, Kubernetes).

juju show-credentials --client localhost localhost --show-secrets

From the output, you will need the values client-cert, client-key, and server-cert. Keep them out of version control (for example, pass them via TF_VAR_... environment variables, a secrets manager, or a .tfvars file you do not commit).

  1. Create a juju_controller resource with your controller name, cloud configuration, and credentials. You can also include controller_config and controller_model_config to configure the controller during bootstrap. For example, for a LXD cloud:

main.tf
resource "juju_controller" "this" {
  name = "test-controller"

  # Use /snap/juju/current/bin/juju if Juju is installed as a snap (avoids confinement issues)
  juju_binary = "/snap/juju/current/bin/juju"

  cloud = {
    name       = "localhost"
    type       = "lxd"
    auth_types = ["certificate"]
  }

  cloud_credential = {
    name      = "localhost"
    auth_type = "certificate"
    attributes = {
      "client-cert" = var.lxd_client_cert
      "client-key"  = var.lxd_client_key
      "server-cert" = var.lxd_server_cert
    }
  }

  # Bootstrap is a good time to set configurations, constraints, etc.

  # Settings here map to flags/config used by `juju controller-config`.
  controller_config = {
    "audit-log-max-backups"     = "10"
    "query-tracing-enabled"     = "true"
  }

  # Settings here map to flags/config used by `juju model-config`.
  controller_model_config = {
    "juju-http-proxy" = "http://my-proxy.internal"
  }
}

After terraform apply, the resource will expose useful read-only attributes such as the controller api_addresses, ca_cert, username, and password.

Configure a controller

A Juju controller can be configured with various settings that control its behavior. There are two types of configuration:

  • Controller configuration (controller_config): Settings specific to the controller itself.

  • Controller model configuration (controller_model_config): Settings for the controller model.

You can configure these settings either during bootstrap or after the controller is created. However, keep in mind that some settings cannot be changed after bootstrap.

During bootstrap

To configure a controller during bootstrap, in your juju_controller resource specify the controller_config and/or controller_model_config attributes. For example:

main.tf
resource "juju_controller" "this" {
  name        = "configured-controller"
  juju_binary = "/snap/juju/current/bin/juju"

  cloud = {
    # cloud configuration...
  }

  cloud_credential = {
    # credential configuration...
  }

  # Controller-specific configuration
  controller_config = {
    "audit-log-max-backups"  = "10"
    "query-tracing-enabled"  = "true"
  }

  # Controller model configuration
  controller_model_config = {
    "juju-http-proxy"   = "http://my-proxy.internal"
    "update-status-hook-interval"  = "1m"
  }
}

Post-bootstrap

To configure a controller post-bootstrap, modify the controller_config or controller_model_config attributes in your Terraform configuration and run terraform apply:

main.tf
resource "juju_controller" "this" {
  # ... existing configuration ...

  controller_config = {
    "audit-log-max-backups"  = "15"      # Updated from 10
    "query-tracing-enabled"  = "true"
    "audit-log-capture-args" = "true"    # Newly added
  }
}

Important

Configuration update behaviors:

  1. Removing a key from controller_config does not unset it on the controller – it remains at its previous value

  2. Some settings cannot be changed after bootstrap. Attempting to change them will result in an error, requiring you to destroy and recreate the controller

  3. Boolean values must be specified as strings: "true" or "false", not bare boolean values

To restore a setting to its default value, you must explicitly set it to the default value rather than removing it from the configuration.

To discover valid configuration keys and values, use juju bootstrap --help or consult the Juju documentation. Many juju_controller resource attributes correspond directly to the flags and config options used by the juju bootstrap command.

Enable controller high availability

Note

Enabling HA relies on Terraform actions, which require Terraform 1.14 or later. For more information, see Terraform actions .

High availability (HA) for a Juju controller ensures that multiple controller units are running so the controller remains available if individual units fail. You can enable HA either during bootstrap or post-bootstrap, and in the latter case you can scale out as well as in.

During bootstrap

To enable controller high availability during bootstrap, in your juju_controller resource, in the lifecycle block, define the action_trigger field. For example:

main.tf
resource "juju_controller" "this" {
  name        = "my-controller"
  juju_binary = "/snap/juju/current/bin/juju"

  cloud = {
    name       = "localhost"
    type       = "lxd"
    auth_types = ["certificate"]
  }

  cloud_credential = {
    name      = "localhost"
    auth_type = "certificate"
    attributes = {
      "client-cert" = var.lxd_client_cert
      "client-key"  = var.lxd_client_key
      "server-cert" = var.lxd_server_cert
    }
  }

  lifecycle {
    ignore_changes = [
      cloud_credential.attributes["client-cert"],
      cloud_credential.attributes["client-key"],
    ]
    action_trigger {
      events  = [after_create]
      actions = [action.juju_enable_ha.this]
    }
  }
}

action "juju_enable_ha" "this" {
  config {
    api_addresses = juju_controller.this.api_addresses
    ca_cert       = juju_controller.this.ca_cert
    username      = juju_controller.this.username
    password      = juju_controller.this.password
    units         = 3
  }
}

Post-bootstrap

To enable controller high availability post-bootstrap, define a Terraform juju_enable_ha action block:

main.tf
action "juju_enable_ha" "this" {
  config {
    api_addresses = juju_controller.this.api_addresses
    ca_cert       = juju_controller.this.ca_cert
    username      = juju_controller.this.username
    password      = juju_controller.this.password
    units         = 5
  }
}

Then run:

terraform apply -invoke=action.juju_enable_ha.this

Terraform will execute the juju_enable_ha action and ensure the controller has the requested number of units.

To scale out the number of units after HA is enabled, update the units value in your juju_enable_ha action and run the terraform apply -invoke=action.juju_enable_ha.this command again. The number of units must always be an odd number.

Note

As with the juju CLI, constraints set while scaling post-bootstrap always apply only to the new units being created.

To scale in, remove backing machines manually via the juju CLI juju remove-machine .

Note

While it is possible to control the number of units or remove machines directly through Terraform, that is currently supported only for regular applications.

Import an existing controller

Note

This operation imports the controller as a resource that Terraform will manage. Controllers cannot be referenced as data sources (read-only). Once imported, Terraform will track the controller’s state and can make changes to its configuration.

To import an existing controller:

  1. Gather the controller’s connection details. You can obtain these by running:

juju show-controller --show-password

From the output, you will need:

  • Controller name

  • API addresses

  • CA certificate

  • Admin username (typically admin)

  • Admin password

  • Controller UUID

  • Credential name

  1. Create an import block with the identity schema containing the controller’s connection information. For example:

main.tf
import {
  to = juju_controller.imported
  identity = {
    name            = "my-existing-controller"
    api_addresses   = ["<ip>:17070"]
    username        = "admin"
    password        = "<password>"
    ca_cert         = <<-EOT
      -----BEGIN CERTIFICATE-----
      -----END CERTIFICATE-----
    EOT
    controller_uuid = "<controller-uuid>"
    credential_name = "<credential-name>"
  }
}
  1. Define the corresponding juju_controller resource with the cloud and credential information. Then run terraform plan. Terraform will detect the import block and import the controller during the next terraform apply. For example:

main.tf
resource "juju_controller" "imported" {
  name = "my-existing-controller"

  cloud = {
    ...
  }

  cloud_credential = {
    ...
    }
  }
Example: LXD controller resource definition for import
main.tf
provider "juju" {
  controller_mode = true
}

resource "juju_controller" "imported" {
  name = "my-lxd-controller"

  juju_binary = "/snap/juju/current/bin/juju"

  cloud = {
    name       = "localhost"
    type       = "lxd"
    auth_types = ["certificate"]
  }

  cloud_credential = {
    name      = "localhost"
    auth_type = "certificate"
    attributes = {
      <attrs>
    }
  }

  lifecycle {
    ignore_changes = [
      cloud.endpoint,
      cloud.region,
      cloud_credential.attributes["client-cert"],
      cloud_credential.attributes["client-key"],
    ]
  }
}

import {
  to = juju_controller.imported
  identity = {
    name            = "my-lxd-controller"
    api_addresses   = ["<ip>:17070"]
    username        = "admin"
    password        = "<password>"
    ca_cert         = <<-EOT
      -----BEGIN CERTIFICATE-----
      -----END CERTIFICATE-----
    EOT
    controller_uuid = "<controller-uuid>"
    credential_name = "<credential-name>"
  }
}

Note

The cloud_credential.attributes["client-cert"] and cloud_credential.attributes["client-key"] are not required to bootstrap an LXD controller, but they are populated in the state during import because they are fetched from the controller. The same applies to cloud.endpoint and cloud.region, which may be set by Juju during bootstrap even if not explicitly specified.

Example: MicroK8s controller resource definition for import
main.tf
provider "juju" {
  controller_mode = true
}

resource "juju_controller" "imported" {
  name = "my-k8s-controller"

  juju_binary = "/snap/juju/current/bin/juju"

  cloud = {
    name                = "test-k8s"
    type                = "kubernetes"
    auth_types          = ["clientcertificate"]
    endpoint            = var.k8s_endpoint
    ca_certificates     = [var.k8s_ca_cert]
    host_cloud_region   = "localhost"
  }

  cloud_credential = {
    name      = "test-credential"
    auth_type = "clientcertificate"
    attributes = {
      "ClientCertificateData" = var.k8s_client_cert
      "ClientKeyData"         = var.k8s_client_key
    }
  }

  lifecycle {
    ignore_changes = [
      cloud.region,
      cloud.host_cloud_region
    ]
  }
}

import {
  to = juju_controller.imported
  identity = {
    name            = "my-k8s-controller"
    api_addresses   = ["<ip>:17070"]
    username        = "admin"
    password        = "<password>"
    ca_cert         = <<-EOT
      -----BEGIN CERTIFICATE-----
      -----END CERTIFICATE-----
    EOT
    controller_uuid = "<controller-uuid>"
    credential_name = "<credential-name>"
  }
}

Note

The cloud.region is not required during bootstrap but may be set by Juju and needs to be ignored. The cloud.host_cloud_region cannot be fetched from the controller after bootstrap, so it must be ignored to prevent Terraform from attempting to replace the controller.

  1. Verify the import:

    1. Run terraform plan to see which attributes Terraform cannot determine or that differ from your configuration. These differences are expected after an import.

    2. Add any fields showing unexpected changes to the lifecycle.ignore_changes block. Common fields to ignore include:

      • Credential attributes that may differ between your plan and the ones fetched from the controller

      • Cloud region and endpoint fields, which may be set by Juju during bootstrap even if not explicitly specified

      • Bootstrap-time configuration that cannot be changed and can’t be fetched from the controller

      For example:

      main.tf
      resource "juju_controller" "imported" {
        # ... existing configuration ...
      
        lifecycle {
          ignore_changes = [
            cloud.endpoint,
            cloud.region,
            cloud_credential.attributes["client-cert"],
            cloud_credential.attributes["client-key"],
          ]
        }
      }
      
    3. Run terraform plan again. You should see either no changes or only expected configuration updates.

Tip

If you see controller_config or controller_model_config showing changes to set default values, you can either apply them (they will update the controller configuration which is idempotent) or add these blocks to ignore_changes to prevent the updates.

Add a cloud to a controller

Add a credential to a controller

By virtue of being bootstrapped into a cloud, your controller already has a credential for that cloud. However, if you want to use a different credential, or if you’re adding a further cloud to the controller and would like to also add a credential for that cloud, you will need to add those credentials to the controller too. You can do that in the usual way by creating a resource of the juju_credential type.

Manage access to a controller

Note

At present the Terraform Provider for Juju supports controller access management only for Juju controllers added to JAAS.

When using Juju with JAAS, to grant access to a Juju controller added to JAAS, in your Terraform plan add a resource type juju_jaas_access_controller. Access can be granted to one or more users, service accounts, roles, and/or groups. You must specify the model UUID, the JAAS controller access level, and the desired list of users, service accounts, roles, and/or groups. For example:

main.tf
resource "juju_jaas_access_controller" "development" {
  access           = "administrator"
  users            = ["foo@domain.com"]
  service_accounts = ["Client-ID-1", "Client-ID-2"]
  roles            = [juju_jaas_role.development.uuid]
  groups           = [juju_jaas_group.development.uuid]
}

Remove a controller

To remove a controller, remove its resource definition from your Terraform plan.