> For the complete documentation index, see [llms.txt](https://docs.drizz.dev/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.drizz.dev/twins/self-hosting/clouds.md).

# AWS, Google Cloud and Azure

Install twins on AWS, Google Cloud or Azure with Terraform modules built on each cloud's managed services.

Install twins on AWS, Google Cloud or Azure with Terraform modules built on each cloud's managed services.

{% hint style="info" %}
**Getting the install files.** The Docker Compose file, Helm chart, Terraform modules and twins images are shared with you by Drizz. Ask your Drizz contact for access before you start.
{% endhint %}

|               |                                                                                                              |
| ------------- | ------------------------------------------------------------------------------------------------------------ |
| **Runs on**   | AWS (ECS on Fargate or EC2) · Google Cloud (GKE) · Azure (AKS)                                               |
| **You need**  | Terraform 1.11 or later, and the cloud's Terraform provider                                                  |
| **Modules**   | `deploy/terraform/aws`, `deploy/terraform/gcp`, `deploy/terraform/azure`, `deploy/terraform/release`         |
| **Watch out** | On Google Cloud and Azure, Terraform's state holds the cache's key. Keep it encrypted and access-controlled. |

## Prerequisites

* Terraform 1.11 or later, so that no password a module makes is kept in its plan or state.
* AWS: the AWS provider 6.62 or later, and the [Session Manager plugin](https://docs.aws.amazon.com/systems-manager/latest/userguide/session-manager-working-with-install-plugin.html) for ECS Exec.
* Google Cloud: the google provider 7.11 or later, before 8.0, `gke-gcloud-auth-plugin`, and the roles and APIs in "Permissions and APIs the apply needs".
* Azure: `kubelogin`, and the resource providers and roles in [Before you start](#before-you-start).

## What each module builds

| Cloud        | Module                                                | Compute                 | Postgres                      | Cache                                         | Worlds' disk       |
| ------------ | ----------------------------------------------------- | ----------------------- | ----------------------------- | --------------------------------------------- | ------------------ |
| AWS          | `deploy/terraform/aws`                                | ECS on Fargate (or EC2) | RDS                           | ElastiCache (Valkey)                          | EFS                |
| Google Cloud | `deploy/terraform/gcp` + `deploy/terraform/release`   | GKE                     | Cloud SQL                     | Memorystore for Redis                         | Persistent disk    |
| Azure        | `deploy/terraform/azure` + `deploy/terraform/release` | AKS                     | Azure Database for PostgreSQL | Azure Managed Redis, or Azure Cache for Redis | Azure managed disk |

On AWS, one module does everything. On Google Cloud and Azure, the cloud module makes the infrastructure and the [release module](#the-release-module) installs twins on the cluster from the [Helm chart](/twins/self-hosting/kubernetes.md).

{% tabs %}
{% tab title="AWS" %}

## AWS

`deploy/terraform/aws` puts twins in your AWS account in one `terraform apply`:

* The twins server as a one-task ECS service on Fargate, behind an application load balancer.
* RDS Postgres, ElastiCache (Valkey) and EFS for the worlds.
* Every world copied continuously to S3.
* Passwords in Secrets Manager, logs in CloudWatch.
* The web console as an opt-in.

It needs Terraform 1.11 or later and the AWS provider 6.62 or later.

### Copy this

```hcl
module "twins" {
  source = "github.com/DrizzDev/twins//deploy/terraform/aws?ref=<tag>"

  vpc_id             = "vpc-0abc"
  public_subnet_ids  = ["subnet-pub-a", "subnet-pub-b"]
  private_subnet_ids = ["subnet-priv-a", "subnet-priv-b"]
  allowed_cidrs      = ["203.0.113.0/24"]                # who reaches the load balancer; ["0.0.0.0/0"] for anyone

  certificate_arn = "arn:aws:acm:...:certificate/..."   # HTTPS; without one, allow_insecure_http = true for a trial
  worlds_base     = "https://twins.acme.com"             # point this name at the load_balancer_dns_name output
  alarm_topic_arn = "arn:aws:sns:...:twins-alarms"       # optional: CloudWatch alarms to this topic

  # the images are private on GHCR: a secret holding {"username", "password"} (a read:packages token)
  repository_credentials_secret_arn = "arn:aws:secretsmanager:...:secret:ghcr"
}
```

### The first key

Sign-in is off by default. Make the first person and their key in the admin task:

```bash
terraform output -raw first_key    # start the admin task, twins enroll and twins issue over ECS Exec, stop it
```

The server's root filesystem is read-only, and ECS Exec needs one the SSM agent can write. So these commands run in an admin task: the same image, settings, secrets and database CA as the server, but no port, no worlds volume and a writable root. It runs only while you use it. Its task role alone holds ECS Exec.

The key goes to your session alone: the cluster logs no ECS Exec session. CloudTrail still records each `ExecuteCommand`, who ran it and on which task. The `admin_task` output gives the same for scripts. ECS Exec needs the [Session Manager plugin](https://docs.aws.amazon.com/systems-manager/latest/userguide/session-manager-working-with-install-plugin.html).

### Inputs that matter

| Input                               | Default                        | What it does                                                                                                                 |
| ----------------------------------- | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------- |
| `vpc_id`                            | Required                       | The VPC twins runs in.                                                                                                       |
| `public_subnet_ids`                 | Required                       | Subnets the load balancer faces the network from: at least two, in different zones.                                          |
| `private_subnet_ids`                | Required                       | Subnets the server, database, cache and storage sit in: at least two, in different zones.                                    |
| `allowed_cidrs`                     | Required                       | Who reaches the load balancer. No default, so who reaches it is always a decision. `["0.0.0.0/0"]` for anyone.               |
| `internal`                          | `false`                        | Puts the load balancer on the private network only.                                                                          |
| `certificate_arn`                   | Empty                          | ACM certificate for HTTPS. Empty means HTTP only, which `allow_insecure_http = true` must then say outright.                 |
| `worlds_base`                       | The load balancer's name       | The address agents reach twins at, written into every URL a world hands out.                                                 |
| `twins_image`                       | `ghcr.io/drizzdev/twins:0.1.0` | The twins image. Change it to upgrade.                                                                                       |
| `repository_credentials_secret_arn` | Empty                          | A Secrets Manager secret with `{"username", "password"}` for a private registry. Empty: the images are public or in ECR.     |
| `capacity`                          | Fargate                        | `{ type = "EC2" }` runs the same tasks on an Auto Scaling group the module makes.                                            |
| `cpu`, `memory`                     | `1024`, `2048`                 | The server task's CPU units and memory in MiB.                                                                               |
| `model_secret_arn`                  | Empty                          | A secret holding an OpenAI API key, for worlds founded from a written brief.                                                 |
| `identity`                          | Sessions off                   | Sign-in through your own OpenID Connect provider. See [Your own sign-in](/twins/self-hosting/your-own-sign-in.md).           |
| `replication`                       | `true`                         | Copies every world to an S3 bucket with Litestream.                                                                          |
| `backup`                            | `true`                         | AWS Backup's daily backup of the worlds' file system, kept 35 days.                                                          |
| `db_multi_az`                       | —                              | A Postgres standby in a second zone.                                                                                         |
| `db_backup_retention_days`          | `35`                           | Days of RDS backups.                                                                                                         |
| `cache_engine`                      | `valkey`                       | `valkey` or `redis`.                                                                                                         |
| `console`                           | Off                            | `{ enabled = true, host = "console.twins.acme.com" }`. The console reaches twins at `worlds_base`, which must then be https. |
| `alarm_topic_arn`                   | Empty                          | SNS topic for CloudWatch alarms. Empty: no alarms. See [Monitoring](/twins/self-hosting/monitoring.md).                      |
| `waf_acl_arn`, `access_logs_bucket` | Empty                          | An AWS WAF web ACL and an access-log bucket for the load balancer.                                                           |
| `deletion_protection`               | `true`                         | Refuses to delete the database and the worlds' storage. Turn it off only to tear a trial down.                               |

Outputs: `url`, `load_balancer_dns_name`, `first_key`, `admin_task`, `restore`, `restore_task`.

### What it creates

| Piece                   | Resource                                                                                                                                                                               |
| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Twins server            | An ECS service of **one** task. ECS replaces it where it fails. A new task waits for the old one's lock before it serves.                                                              |
| Worlds' data            | EFS, encrypted, with an access point owned by the image's user (999). Backed up daily by AWS Backup and kept 35 days (`backup`).                                                       |
| Worlds' copies          | S3 (`replication`, on by default): Litestream streams every world there. Public access blocked, encrypted, TLS only, versioned.                                                        |
| Accounts, keys, records | RDS Postgres, encrypted, TLS checked by name against the RDS CA. Backups kept `db_backup_retention_days` (35).                                                                         |
| Request allowance       | ElastiCache Valkey 8.1, encrypted at rest and in transit, with a token.                                                                                                                |
| Passwords               | The database password, the cache token and the console secret, drawn at apply and written write-only to Secrets Manager. None is in a task definition, a plan, the state or an output. |
| Traffic                 | An application load balancer: HTTPS with your certificate, `allowed_cidrs` only, a 300 s idle timeout. Health checked at `/ready`.                                                     |
| Logs and alarms         | CloudWatch logs (`log_retention_days`, 30) and, with `alarm_topic_arn`, CloudWatch alarms.                                                                                             |
| Admin and restore tasks | Task definitions run only by hand: one for `twins enroll`, `issue`, `role` and `organization`; one to put the worlds back.                                                             |

Every task runs as its image's own non-root user with a read-only root filesystem. The admin task alone, started by hand, has a writable root.

<details>

<summary>Network: the tasks' way out</summary>

The tasks sit in the private subnets with no public address. They need a way out to AWS's APIs and the registry: a NAT gateway, or VPC endpoints for each of these ([ECS VPC endpoints](https://docs.aws.amazon.com/AmazonECS/latest/developerguide/vpc-endpoints.html)):

| Endpoint                                                               | For                                                                                                                                                   |
| ---------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| `com.amazonaws.<region>.secretsmanager` (interface)                    | The tasks' secrets, at start.                                                                                                                         |
| `com.amazonaws.<region>.logs` (interface)                              | The tasks' logs.                                                                                                                                      |
| `com.amazonaws.<region>.s3` (gateway)                                  | The worlds' copies, and ECR's image layers.                                                                                                           |
| `com.amazonaws.<region>.ecr.api`, `ecr.dkr` (interface)                | Images in ECR ([ECR VPC endpoints](https://docs.aws.amazon.com/AmazonECR/latest/userguide/vpc-endpoints.html)). An image on GHCR needs a NAT gateway. |
| `com.amazonaws.<region>.ssmmessages` (interface)                       | ECS Exec, for the admin task.                                                                                                                         |
| `com.amazonaws.<region>.ecs`, `ecs-agent`, `ecs-telemetry` (interface) | The ECS agent, on the EC2 shape alone. Fargate needs none.                                                                                            |

A model (`model_secret_arn`) and an identity provider outside the VPC need a NAT gateway.

</details>

<details>

<summary>Deployments and the one writer</summary>

The server keeps its worlds in SQLite files on EFS and must be their only writer. Its service runs one task with `deployment_minimum_healthy_percent = 0` and `deployment_maximum_percent = 100` ([DeploymentConfiguration](https://docs.aws.amazon.com/AmazonECS/latest/APIReference/API_DeploymentConfiguration.html)).

ECS can still start the new task while the old one is leaving the load balancer or shutting down. For up to about three minutes both tasks can be up. The server handles this: before it serves, it takes an exclusive lock on `.writer.lock` under its root on EFS, and answers `/ready` with 503 until it holds it. The new task waits for the old one to exit and never writes beside it.

Availability Zone rebalancing is off on the server's service. It would start a task in one zone before stopping one in another, which one writer cannot allow ([service rebalancing](https://docs.aws.amazon.com/AmazonECS/latest/developerguide/service-rebalancing.html)).

</details>

<details>

<summary>On EC2 instances of your own</summary>

`capacity = { type = "EC2" }` runs the same tasks on an Auto Scaling group the module makes: ECS-optimized Amazon Linux 2023 (or `capacity.ami` for a hardened image of your own), IMDSv2 only, encrypted disks. ECS scales it between `capacity.min` and `capacity.max`.

```hcl
capacity = { type = "EC2", instance_type = "m7i.large", min = 0, max = 4 }
```

Each task takes a network interface of the instance's own, and an instance has few. An `m7i.large`, the default, holds two tasks. Turn on ENI trunking for the account and it holds ten ([ENI trunking](https://docs.aws.amazon.com/AmazonECS/latest/developerguide/eni-trunking-supported-instance-types.html)). Do this before the instances are launched:

```bash
aws ecs put-account-setting-default --name awsvpcTrunking --value enabled
```

The type must support trunking. `t3` types do not.

</details>

<details>

<summary>Passwords and rotation</summary>

None is in a plan, the state, a task definition or an output.

* **The database's** is drawn at apply and written to its Secrets Manager secret. Raise `database_password_version` for a new one, then run `aws ecs update-service --force-new-deployment` to give the server the new one. Between the two, new connections fail.
* **The cache's token** is drawn the same way. Raise `cache_token_version` for a new one. The cache takes it beside the old one, so the server keeps working until a deployment gives it the new one.
* **The console's session secret** is drawn and written the same way.

Secrets you name (`repository_credentials_secret_arn`, `model_secret_arn`) that are encrypted with a customer managed key need that key in `secrets_kms_key_arns`.

</details>
{% endtab %}

{% tab title="Google Cloud" %}

## Google Cloud

`deploy/terraform/gcp` makes the infrastructure: a GKE cluster, Cloud SQL for PostgreSQL and Memorystore for Redis on a private network. The [release module](#the-release-module) then installs twins on it from the chart.

* The twins server runs as a StatefulSet of one on a persistent disk.
* Cloud SQL has a private IP only. Memorystore is reached over the VPC with an AUTH string. Both use TLS with the server's certificate checked.
* The database password is kept in Secret Manager and out of Terraform's state.
* The cluster's control plane answers only the ranges you list.

It needs Terraform 1.11 or later and the google provider 7.11 or later, before 8.0.

### Copy this

```hcl
module "gcp" {
  source            = "github.com/DrizzDev/twins//deploy/terraform/gcp?ref=<tag>"
  project           = "acme-twins"
  region            = "europe-west1"
  api_allowed_cidrs = ["203.0.113.0/24"] # your offices, VPN and CI
}

provider "kubernetes" {
  host                   = module.gcp.cluster_host
  cluster_ca_certificate = base64decode(module.gcp.cluster_ca_certificate)
  exec {
    api_version = "client.authentication.k8s.io/v1beta1"
    command     = "gke-gcloud-auth-plugin"
  }
}

provider "helm" {
  kubernetes = {
    host                   = module.gcp.cluster_host
    cluster_ca_certificate = base64decode(module.gcp.cluster_ca_certificate)
    exec = {
      api_version = "client.authentication.k8s.io/v1beta1"
      command     = "gke-gcloud-auth-plugin"
    }
  }
}

module "twins" {
  source                    = "github.com/DrizzDev/twins//deploy/terraform/release?ref=<tag>"
  database_dsn              = module.gcp.database_dsn
  database_password         = module.gcp.database_password
  database_password_version = module.gcp.database_password_version
  database_ca               = module.gcp.database_ca_certificate
  redis_url                 = module.gcp.redis_url
  redis_ca                  = module.gcp.redis_ca_certificate
  storage_class             = module.gcp.storage_class
}
```

The providers sign in with `gke-gcloud-auth-plugin` (`gcloud components install gke-gcloud-auth-plugin`). It fetches a fresh token whenever one is needed, as [Google's guide](https://registry.terraform.io/providers/hashicorp/google/latest/docs/guides/using_gke_with_terraform) advises.

### The first key

`terraform output` on the release gives `first_key`: the two `kubectl exec` commands that make the first person and their key on the server.

### Inputs that matter

| Input                                              | Default                                    | What it does                                                                                                                                                    |
| -------------------------------------------------- | ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `project`, `region`                                | Required                                   | Where it runs.                                                                                                                                                  |
| `api_allowed_cidrs`                                | Required                                   | The ranges the control plane answers. Include wherever Terraform and kubectl run from. `["0.0.0.0/0"]` opens it to the internet, behind Google's sign-in alone. |
| `control_plane_dns_endpoint`                       | On                                         | The control plane's DNS name, which lets in only Google identities that hold `container.clusters.connect`. `false` keeps it to Google's own services.           |
| `location`                                         | `europe-west1-b` (a zone)                  | Set it to the region rather than a zone for a regional cluster that survives a zone's outage. Changing it replaces the cluster.                                 |
| `postgres_high_availability`                       | On (REGIONAL)                              | A Cloud SQL standby in a second zone. Keep ZONAL for a trial.                                                                                                   |
| `redis_tier`                                       | `BASIC`                                    | `STANDARD_HA` adds a Memorystore replica in a second zone.                                                                                                      |
| `node_min_count`, `node_max_count`                 | —                                          | `node_max_count` turns on the cluster autoscaler.                                                                                                               |
| `database_encryption_key`                          | Optional                                   | A Cloud KMS key that encrypts the cluster's Secrets.                                                                                                            |
| `memorystore`                                      | On                                         | `false` runs the chart's own cache inside the cluster instead.                                                                                                  |
| `cloud_nat`                                        | On                                         | Gives private nodes their way out to pull images from outside Google. `false` leaves them Google's APIs and Artifact Registry alone.                            |
| `create_service_networking`                        | On                                         | `false` takes the peering with Google's service producers as already made (a shared VPC).                                                                       |
| `backup_retain_days`                               | `14`                                       | Days of Backup for GKE's nightly backups.                                                                                                                       |
| `alert_notification_channels`                      | Empty                                      | Turns on log-based alert policies. See [Monitoring](/twins/self-hosting/monitoring.md).                                                                         |
| `maintenance_window`, `cluster_maintenance_window` | Sunday 04:00 UTC; weekends 00:00–06:00 UTC | When Google may restart Cloud SQL and Memorystore, and upgrade the cluster.                                                                                     |
| `database_password_version`                        | —                                          | Raise it by one and apply to rotate the database password.                                                                                                      |
| `deletion_protection`                              | On                                         | Set it to `false` and apply before `terraform destroy`.                                                                                                         |

The chart's values go in the release's `values`. See [Kubernetes](/twins/self-hosting/kubernetes.md).

<details>

<summary>Permissions and APIs the apply needs</summary>

Whoever applies the module (a person or CI's service account) needs these roles on the project, or the project's owner:

| Role                                    | For                                                                                                                                   |
| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| `roles/container.admin`                 | The GKE cluster and node pool                                                                                                         |
| `roles/compute.networkAdmin`            | The VPC, subnet, Cloud NAT and the private services range and peering                                                                 |
| `roles/cloudsql.admin`                  | The Cloud SQL instance, database and user                                                                                             |
| `roles/redis.admin`                     | Memorystore                                                                                                                           |
| `roles/gkebackup.admin`                 | Backup for GKE's backup plan and restore plan (or `roles/gkebackup.backupAdmin` with `roles/gkebackup.restoreAdmin`)                  |
| `roles/secretmanager.admin`             | The database password's secret                                                                                                        |
| `roles/iam.serviceAccountAdmin`         | The nodes' service account                                                                                                            |
| `roles/iam.serviceAccountUser`          | Running every node as the nodes' service account ([GKE's IAM roles](https://docs.cloud.google.com/kubernetes-engine/docs/how-to/iam)) |
| `roles/resourcemanager.projectIamAdmin` | Granting the nodes' service account its two project roles                                                                             |

And these APIs on the project:

```bash
gcloud services enable compute.googleapis.com container.googleapis.com sqladmin.googleapis.com redis.googleapis.com \
  servicenetworking.googleapis.com gkebackup.googleapis.com artifactregistry.googleapis.com iam.googleapis.com \
  cloudresourcemanager.googleapis.com secretmanager.googleapis.com --project <project>
```

</details>

<details>

<summary>Terraform state</summary>

Memorystore's AUTH string is made by Google, and the provider keeps it in the state (`redis_url`) with no write-only form. Keep the state where it is encrypted and access-controlled, never on a laptop or in Git. Use a [GCS backend](https://developer.hashicorp.com/terraform/language/backend/gcs) on a bucket of its own, with object versioning on and a Cloud KMS key of yours:

```hcl
terraform {
  backend "gcs" {
    bucket             = "acme-twins-tfstate"
    prefix             = "twins"
    kms_encryption_key = "projects/acme-twins/locations/europe-west1/keyRings/tfstate/cryptoKeys/tfstate"
  }
}
```

</details>

<details>

<summary>Changes that replace the cluster</summary>

The cluster and what runs on it are in one root module, so one `terraform apply` installs everything. The kubernetes provider [advises](https://registry.terraform.io/providers/hashicorp/kubernetes/latest/docs#stacking-with-managed-kubernetes-cluster-resources) against this for a configuration that lives on. For a change that replaces the cluster, apply it first, then the rest:

```bash
terraform apply -target=module.gcp
terraform apply
```

To avoid the question, keep the release in a root module of its own that reads the cluster with `data "google_container_cluster"`.

</details>
{% endtab %}

{% tab title="Azure" %}

## Azure

`deploy/terraform/azure` makes the infrastructure: an AKS cluster, Azure Database for PostgreSQL and Azure Managed Redis on a private network. The [release module](#the-release-module) then installs twins on it from the chart.

* The twins server runs as a StatefulSet of one on an Azure managed disk.
* Postgres takes connections from the virtual network only, over TLS 1.3 with its certificate checked.
* Redis is reached through a private endpoint, over TLS. `redis_service = "cache"` uses Azure Cache for Redis instead.
* People and Terraform sign in to the cluster through Entra ID. It has no local accounts.
* The database password is kept in Key Vault and never written to Terraform's state.

### Before you start

* Terraform 1.11 or later.
* The subscription's resource providers registered once: `az provider register --namespace <name>` for `Microsoft.ContainerService`, `Microsoft.KubernetesConfiguration`, `Microsoft.DataProtection`, `Microsoft.PolicyInsights`, `Microsoft.KeyVault`, `Microsoft.DBforPostgreSQL` and `Microsoft.Cache` ([Azure Backup for AKS prerequisites](https://learn.microsoft.com/azure/backup/azure-kubernetes-service-cluster-backup-concept)).
* The identity running Terraform may assign roles (Owner, or User Access Administrator beside Contributor). Where it may not, turn `cluster_network_contributor`, `terraform_secrets_officer` and `terraform_cluster_admin` off and have the roles granted beforehand.
* Azure Policy assignments that reach the cluster leave out the `dataprotection-microsoft` namespace, where the backup extension runs.
* Terraform's state in an encrypted remote backend (see below).

### Copy this

```hcl
provider "azurerm" {
  features {
    # the backup storage account takes Entra ID alone, no keys: Terraform reads it through Azure Resource Manager
    storage {
      data_plane_available = false
    }
  }
}

module "azure" {
  source   = "github.com/DrizzDev/twins//deploy/terraform/azure?ref=<tag>"
  name     = "twins"
  location = "westeurope"
  # where kubectl and Terraform reach the cluster's API server and the Key Vault from; ["0.0.0.0/0"] leaves them open
  api_allowed_cidrs = ["203.0.113.0/24"]
  # optional: Entra ID groups whose members administer the cluster
  admin_group_object_ids = ["00000000-0000-0000-0000-000000000000"]
}

provider "kubernetes" {
  host                   = module.azure.cluster_host
  cluster_ca_certificate = base64decode(module.azure.cluster_ca_certificate)
  exec {
    api_version = "client.authentication.k8s.io/v1beta1"
    command     = "kubelogin"
    args        = ["get-token", "--login", "azurecli", "--server-id", "6dae42f8-4368-4678-94ff-3960e28e3630"]
  }
}

provider "helm" {
  kubernetes = {
    host                   = module.azure.cluster_host
    cluster_ca_certificate = base64decode(module.azure.cluster_ca_certificate)
    exec = {
      api_version = "client.authentication.k8s.io/v1beta1"
      command     = "kubelogin"
      args        = ["get-token", "--login", "azurecli", "--server-id", "6dae42f8-4368-4678-94ff-3960e28e3630"]
    }
  }
}

module "twins" {
  source                    = "github.com/DrizzDev/twins//deploy/terraform/release?ref=<tag>"
  database_dsn              = module.azure.database_dsn
  database_password         = module.azure.database_password
  database_password_version = module.azure.database_password_version
  database_ca_system        = true
  redis_url                 = module.azure.redis_url
  storage_class             = module.azure.storage_class
  values = [yamlencode({
    ingress = { enabled = true, className = "nginx", hosts = { worlds = "twins.acme.com" }, tls = { enabled = true } }
  })]
}
```

The providers sign in as whoever ran `az login`, through [kubelogin](https://learn.microsoft.com/azure/aks/kubelogin-authentication) (`az aks install-cli` installs it). `6dae42f8-4368-4678-94ff-3960e28e3630` is the AKS server application, the same in every tenant. In CI, change `--login azurecli` to `msi`, `spn` or `workloadidentity`, or sign the Azure CLI in as the pipeline.

A new role takes minutes to take effect, so the module waits after each role it grants (`role_propagation_seconds`). Should a first apply still stop at `Forbidden` or `403`, apply again.

For kubectl, run `az aks get-credentials` with the `resource_group` and `cluster_name` outputs.

### The first key

`terraform output` on the release gives `first_key`: the two `kubectl exec` commands that make the first person and their key on the server.

### Inputs that matter

| Input                                         | Default                              | What it does                                                                                                                        |
| --------------------------------------------- | ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- |
| `name`, `location`                            | `twins`, `westeurope`                | Resource name prefix and Azure region.                                                                                              |
| `api_allowed_cidrs`                           | Required                             | Where Terraform and kubectl reach the API server and Key Vault from. Public addresses only: Key Vault takes no private range.       |
| `admin_group_object_ids`                      | Optional                             | Entra ID groups whose members administer the cluster.                                                                               |
| `node_count`, `node_size`, `zones`            | `2` nodes across zones `1`, `2`, `3` | The node pool. Across `zones`, the worlds' disk is zone-redundant (Premium\_ZRS).                                                   |
| `sku_tier`                                    | Standard                             | The AKS tier. `"Free"` for the free tier.                                                                                           |
| `redis_service`                               | Azure Managed Redis                  | `"cache"` uses Azure Cache for Redis.                                                                                               |
| `postgres_high_availability`                  | `false`                              | A Postgres standby in another zone.                                                                                                 |
| `geo_redundant_backup`                        | `false`                              | Geo-redundant Postgres backups. Set only when the server is made.                                                                   |
| `backup_retention_days`, `backup_retain_days` | `30`, `30`                           | Days of Postgres backups and of the disk's nightly backups. Keep them equal.                                                        |
| `outbound_type`                               | Load balancer                        | `userDefinedRouting` (with `route_table_id`, `egress_cidrs`) or `userAssignedNATGateway` (with `nat_gateway_id`) for hub and spoke. |
| `monitoring`                                  | On                                   | Container Insights into a Log Analytics workspace.                                                                                  |
| `alert_action_group_ids`                      | Empty                                | Turns on log alert rules. See [Monitoring](/twins/self-hosting/monitoring.md).                                                      |
| `defender`                                    | `false`                              | Installs Microsoft Defender for Containers' sensor.                                                                                 |
| `acr_id`                                      | —                                    | Lets the nodes pull a private copy of the twins image from Azure Container Registry.                                                |
| `database_password_version`                   | —                                    | Raise it to rotate the database password.                                                                                           |
| `deletion_protection`                         | On                                   | A lock against deleting the database.                                                                                               |

Chart values (ingress, console, sign-in through your own provider, a private image registry) go in the release's `values`. See [Kubernetes](/twins/self-hosting/kubernetes.md).

<details>

<summary>Terraform state</summary>

The database password is in no state, but other secrets are: the Redis access key (`redis_url`) and whatever the release keeps. Keep state in the [azurerm backend](https://developer.hashicorp.com/terraform/language/backend/azurerm), signed in to with Entra ID (`use_azuread_auth = true`) rather than an account key, the account's keys off and its readers few. Never a local `terraform.tfstate` that is copied around.

</details>

<details>

<summary>Break glass: signing in when Entra ID cannot</summary>

The cluster has no local accounts, so Entra ID roles are the only way in. The module refuses to make a cluster that neither `terraform_cluster_admin` nor `admin_group_object_ids` gives an administrator.

Keep a break-glass path that does not hang on one person or one pipeline: an Entra ID group in `admin_group_object_ids` holding an emergency account that Conditional Access leaves out, its use alerted on. A subscription Owner can also give anyone the Azure Kubernetes Service RBAC Cluster Admin role with `az role assignment create`.

The last resort, `az aks update --enable-local-accounts` and `az aks get-credentials --admin`, brings back a certificate that never expires. Turn local accounts off again afterwards and rotate the cluster's certificates (`az aks rotate-certs`) ([local accounts](https://learn.microsoft.com/azure/aks/local-accounts)).

</details>

<details>

<summary>Changes that replace the cluster</summary>

For a change that replaces the cluster, apply it first, then the rest:

```bash
terraform apply -target=module.azure
terraform apply
```

To avoid the question, keep the release in a root module of its own that reads the cluster with `data "azurerm_kubernetes_cluster"`.

</details>
{% endtab %}
{% endtabs %}

## The release module

`deploy/terraform/release` installs twins on a cluster from the chart, pointed at a managed Postgres and Redis. The Google Cloud and Azure modules use it.

| Input                       | Default                | What it does                                                                                                                    |
| --------------------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `namespace`                 | `twins`                | The namespace twins is installed in.                                                                                            |
| `database_dsn`              | Required               | The managed Postgres's address: without its password where `database_password` gives it, else with it.                          |
| `database_password`         | `null`                 | The Postgres password, kept out of the address and out of Terraform's state. The server reads it as a file on every connection. |
| `database_password_version` | `0`                    | The version of `database_password`. Raise it whenever the password changes.                                                     |
| `redis_url`                 | Empty                  | The managed cache's address, key included. Empty runs a Valkey inside the release.                                              |
| `database_ca`               | Empty                  | The CA the Postgres certificate is checked against, PEM (Cloud SQL's server CA).                                                |
| `database_ca_system`        | `false`                | Check the Postgres certificate against the image's public CAs (Azure Database for PostgreSQL).                                  |
| `redis_ca`                  | Empty                  | The CA the cache's certificate is checked against, PEM (Memorystore's). Empty: the image's public CAs.                          |
| `storage_class`             | Required               | The storage class of the server's disk: the cloud's block storage.                                                              |
| `chart`                     | The repository's chart | The chart, or an OCI reference to a published one.                                                                              |
| `pod_security_version`      | `v1.35`                | The Kubernetes version whose "restricted" pod security standard the namespace enforces.                                         |
| `values`                    | `[]`                   | Further chart values, as YAML documents applied in order (ingress, console, identity, images).                                  |

Outputs: `namespace` and `first_key`.

## Next

* [Upgrades](/twins/self-hosting/upgrades.md): change the image and apply.
* [Backups](/twins/self-hosting/backups.md): what each module backs up, and how to restore.
* [Monitoring](/twins/self-hosting/monitoring.md): CloudWatch, Cloud Monitoring and Azure Monitor alerts.

***

*Last updated: 27 October 2026*
