# main.tf — generated by `autumn release init --target azure-container-apps`
#
# Provisions {{project_name}} on Azure Container Apps: a resource group, an
# Azure Container Registry, a Container Apps environment + the app itself, a
# one-shot migration job, Azure Database for PostgreSQL Flexible Server, and
# a Key Vault that feeds the app's secrets in as Container Apps secret refs
# (never as plain env values). Redis is optional and off by default — see
# `enable_redis_cache`.
#
# Secrets (`signing_secret`, `database_admin_password`) have no default in
# variables.tf. Supply them via `TF_VAR_*` environment variables or your CI's
# secret store — never commit real values here or in terraform.tfvars. The
# database connection string is NOT a variable: it's derived below from the
# Postgres server this same apply creates, so one `terraform apply` is
# enough — no second, targeted apply to fill in a value that depends on a
# resource the first apply was about to create.
#
# The app and migration job both start from a public placeholder image
# (`var.bootstrap_image`): Container Apps must pull an image to create a
# job's/app's first revision, and a brand-new ACR has none yet. CI
# (azure-deploy.yml) swaps in the real image on every deploy via
# azure-cutover.sh/`az containerapp job update --image`, and Terraform
# is told to ignore that drift (see the `lifecycle` blocks below) so a later
# `terraform apply` doesn't revert a live deploy back to the placeholder.
#
# The app starts with no managed identity, registry or secret refs (#2314),
# so the public placeholder never gets production credentials. The cutover
# script (azure-cutover.sh) copies them from the migration job and sets the
# real image in one write. The job also has them. It runs only when you or
# CI start it, after the real image is set on it.
terraform {
required_version = ">= 1.5"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
random = {
source = "hashicorp/random"
version = "~> 3.6"
}
}
}
provider "azurerm" {
features {}
# AzureRM v4 made subscription_id mandatory for plan/apply — even under
# `az login` CLI auth — see the 4.0 upgrade guide:
# https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/guides/4.0-upgrade-guide#specifying-subscription-id-is-now-mandatory
subscription_id = var.subscription_id
}
data "azurerm_client_config" "current" {}
# Azure names for ACR/Key Vault/Postgres must be globally unique; this suffix
# keeps re-runs of `terraform apply` stable while avoiding collisions with
# other Autumn deployments.
resource "random_id" "suffix" {
byte_length = 4
}
locals {
# ACR names allow lowercase alphanumerics only (no hyphens) — strip
# everything else once here instead of re-deriving it per resource, and
# cap at 30 characters. ACR is the tightest consumer: its 50-character
# limit minus the fixed "acr" + 8-hex-char suffix appended below leaves
# room for at most 39; 30 leaves comfortable margin and is also safely
# under Postgres/Redis's own 63-character limits after their longer fixed
# suffixes. (Key Vault truncates independently, tighter still, below.)
#
# A Cargo package name made entirely of characters this strips (e.g. the
# legal-but-unusual name "_") sanitizes to an empty string, which would
# otherwise produce a Postgres server name starting with "-" (main.tf's
# "${app_name_alnum}-pg-..." pattern) or violate a resource type that
# (unlike ACR) requires the name to start with a letter, not just be
# alphanumeric — Key Vault does. So: fall back to a fixed "app" prefix
# whenever sanitization leaves nothing, or leaves a value not starting
# with a letter (a leading digit survives sanitization but several of
# these resource types don't accept it).
app_name_alnum_raw = lower(replace(var.app_name, "/[^a-zA-Z0-9]/", ""))
app_name_alnum = substr(
local.app_name_alnum_raw == "" ? "app" : (
can(regex("^[a-z]", local.app_name_alnum_raw)) ? local.app_name_alnum_raw : "app${local.app_name_alnum_raw}"
),
0, 30
)
# Container Apps-family resources (the environment, the app, the migration
# job, and Log Analytics) allow lowercase alphanumerics AND hyphens, must
# be 2-32 characters, must start with a letter, must not contain
# consecutive hyphens, and must end in an alphanumeric character.
# `var.app_name` comes straight from the Cargo package name by default,
# which — unlike these Azure names — may legally contain underscores or
# uppercase letters, or its own hyphens adjacent to one mapped from an
# underscore (e.g. "my_app" and "my-app" both risk producing "my--app" if
# invalid characters are merely swapped for a hyphen one-for-one), may be
# entirely characters that sanitize away to nothing (e.g. "_"), and may be
# as short as one character or longer than 32. So: map anything else to a
# hyphen, collapse any resulting run of hyphens to one, trim a
# leading/trailing hyphen, fall back to a fixed "app" prefix if that
# leaves nothing or leaves a non-letter-leading value, pad a too-short
# result up to Azure's 2-character minimum, then cap the base at 24
# characters — leaving headroom for the longest suffix appended to any of
# these resources ("-migrate", 8 characters) so the full name never
# exceeds Azure's 32-character maximum — and trim once more in case
# truncation happened to land on a hyphen.
app_name_hyphenated_raw = trim(
replace(
lower(replace(var.app_name, "/[^a-zA-Z0-9-]/", "-")),
"/-+/", "-"
),
"-"
)
app_name_hyphenated = (
local.app_name_hyphenated_raw == "" ? "app" : (
can(regex("^[a-z]", local.app_name_hyphenated_raw)) ? local.app_name_hyphenated_raw : "app-${local.app_name_hyphenated_raw}"
)
)
app_name_safe = trim(
substr(
length(local.app_name_hyphenated) < 2 ? "${local.app_name_hyphenated}00" : local.app_name_hyphenated,
0, 24
),
"-"
)
}
# Azure Container Apps' default ingress hostname is deterministic:
# "<app-name>.<environment's default domain>". Computed from the
# environment (created before the app) rather than from
# azurerm_container_app.this.latest_revision_fqdn — referencing the app's
# own only-known-after-create output as one of its own env values would be
# a circular self-reference Terraform can't resolve. In practice this
# resolves to the same hostname a real client's Host header carries.
locals {
app_fqdn = "${local.app_name_safe}.${azurerm_container_app_environment.this.default_domain}"
# Azure Database for PostgreSQL Flexible Server rejects non-alphanumeric
# characters in administrator_login (no underscore/hyphen) — a fixed,
# single source used by both the server resource and the derived
# database_url below, so they can't drift out of sync.
postgres_admin_login = "autumnadmin"
# A fresh Flexible Server already contains "postgres", "azure_maintenance",
# and "azure_sys" as Azure-specific system databases, plus "template0" and
# "template1" — every Postgres cluster, on any host, is initialized with
# those two as the templates new databases are cloned from. A Cargo
# package literally named any of those (or e.g. "azure-sys"/"template0",
# which sanitize to the same underscored name) would derive an identical
# database name here, and `terraform apply` fails trying to create/manage
# a database that already exists. Append a suffix in that case;
# re-truncate first so the suffixed name still respects the 63-byte limit
# even though every reserved name is far short of it today.
postgres_database_name_raw = substr(replace(local.app_name_hyphenated, "-", "_"), 0, 63)
postgres_database_name = contains(
["postgres", "azure_maintenance", "azure_sys", "template0", "template1"],
local.postgres_database_name_raw
) ? "${substr(local.postgres_database_name_raw, 0, 58)}_prod" : local.postgres_database_name_raw
}
resource "azurerm_resource_group" "this" {
# local.app_name_safe, not raw var.app_name: resource groups (90-char
# limit) and the identity below (128-char limit) both accept far more
# generous lengths than Container Apps-family resources, but a Cargo
# package name is unbounded — a name longer than 87 characters would
# overflow the resource group's own limit once "-rg" is appended.
# app_name_safe is already bounded well under either ceiling.
name = "${local.app_name_safe}-rg"
location = var.location
}
resource "azurerm_container_registry" "this" {
name = "${local.app_name_alnum}acr${random_id.suffix.hex}"
resource_group_name = azurerm_resource_group.this.name
location = azurerm_resource_group.this.location
sku = "Basic"
admin_enabled = false
}
resource "azurerm_log_analytics_workspace" "this" {
name = "${local.app_name_safe}-logs"
resource_group_name = azurerm_resource_group.this.name
location = azurerm_resource_group.this.location
sku = "PerGB2018"
retention_in_days = 30
}
resource "azurerm_container_app_environment" "this" {
name = "${local.app_name_safe}-env"
resource_group_name = azurerm_resource_group.this.name
location = azurerm_resource_group.this.location
log_analytics_workspace_id = azurerm_log_analytics_workspace.this.id
}
# The Container App authenticates to ACR and Key Vault with this identity
# instead of admin credentials or committed secrets.
resource "azurerm_user_assigned_identity" "this" {
name = "${local.app_name_safe}-identity"
resource_group_name = azurerm_resource_group.this.name
location = azurerm_resource_group.this.location
}
resource "azurerm_role_assignment" "acr_pull" {
scope = azurerm_container_registry.this.id
role_definition_name = "AcrPull"
principal_id = azurerm_user_assigned_identity.this.principal_id
# This grant targets the identity the same apply just created above — a
# user-assigned identity IS backed by a Service Principal object in Entra
# ID under the hood, so on a fresh apply, AAD replication lag can make
# this role assignment fail with PrincipalNotFound before the object has
# propagated, even though the identity was just created successfully.
# skip_service_principal_aad_check exists for exactly this case.
skip_service_principal_aad_check = true
}
resource "azurerm_key_vault" "this" {
# Key Vault names are capped at 24 characters: 10 (app prefix) + 2 ("kv")
# + 8 (random_id.suffix.hex, byte_length = 4) = 20, with headroom.
name = "${substr(local.app_name_alnum, 0, 10)}kv${random_id.suffix.hex}"
resource_group_name = azurerm_resource_group.this.name
location = azurerm_resource_group.this.location
tenant_id = data.azurerm_client_config.current.tenant_id
sku_name = "standard"
# Scaffold default: lets `terraform destroy`/recreate cycles run without
# waiting out Key Vault's soft-delete retention. Set to `true` before
# production — with it `false`, a purged vault's secrets are gone for
# good, but so is any accidental `terraform destroy`'s recovery window.
purge_protection_enabled = false
}
# Grants Terraform's own caller identity Key Vault access so `terraform
# apply` can create the secrets below. Access-policy-model vaults (the
# default here) grant NO data-plane access by default — subscription-level
# Owner/Contributor does not imply Key Vault secret access — so without this,
# `azurerm_key_vault_secret.database_url`/`signing_secret` fail at apply
# time with a 403.
resource "azurerm_key_vault_access_policy" "terraform" {
key_vault_id = azurerm_key_vault.this.id
tenant_id = data.azurerm_client_config.current.tenant_id
object_id = data.azurerm_client_config.current.object_id
secret_permissions = ["Get", "List", "Set", "Delete", "Purge"]
}
resource "azurerm_key_vault_access_policy" "container_app" {
key_vault_id = azurerm_key_vault.this.id
tenant_id = data.azurerm_client_config.current.tenant_id
object_id = azurerm_user_assigned_identity.this.principal_id
secret_permissions = ["Get", "List"]
}
# AUTUMN_DATABASE__PRIMARY_URL — mirrors the same env var name used by the
# fly/docker-compose targets; only the binding mechanism (Key Vault secret
# ref) differs. Derived from the Postgres server this same apply creates
# (not a variable) so a single `terraform apply` provisions the server AND
# the secret that points at it — no second apply needed to fill in a value
# that only exists once the first apply finishes.
resource "azurerm_key_vault_secret" "database_url" {
name = "AUTUMN-DATABASE-PRIMARY-URL"
value = "postgres://${local.postgres_admin_login}:${urlencode(var.database_admin_password)}@${azurerm_postgresql_flexible_server.this.fqdn}:5432/${azurerm_postgresql_flexible_server_database.this.name}?sslmode=require"
key_vault_id = azurerm_key_vault.this.id
depends_on = [azurerm_key_vault_access_policy.terraform]
}
# AUTUMN_SECURITY__SIGNING_SECRET — required in production; see
# docs/guide/signing-secrets.md. Generate with `openssl rand -hex 32`.
resource "azurerm_key_vault_secret" "signing_secret" {
name = "AUTUMN-SECURITY-SIGNING-SECRET"
value = var.signing_secret
key_vault_id = azurerm_key_vault.this.id
depends_on = [azurerm_key_vault_access_policy.terraform]
}
resource "azurerm_postgresql_flexible_server" "this" {
name = "${local.app_name_alnum}-pg-${random_id.suffix.hex}"
resource_group_name = azurerm_resource_group.this.name
location = azurerm_resource_group.this.location
version = "16"
administrator_login = local.postgres_admin_login
administrator_password = var.database_admin_password
storage_mb = 32768
sku_name = var.db_sku
# No explicit `zone` — not every Container Apps region offers Postgres
# Flexible Server availability zone 1, and a hardcoded zone fails
# `terraform apply` in those regions even though an unzoned server would
# succeed. Omitting it lets Azure pick an available placement itself.
public_network_access_enabled = true
}
resource "azurerm_postgresql_flexible_server_database" "this" {
# Postgres database identifiers are capped at 63 bytes; a long Cargo
# package name (up to 64 characters) would otherwise overflow it.
# local.postgres_database_name reuses the already-sanitized (lowercase,
# alphanumeric-and-hyphens, collapsed/trimmed) app_name_hyphenated local
# rather than re-deriving sanitization here, swapping hyphens for
# underscores since Postgres identifiers conventionally use those as the
# word separator, and guards against the handful of names Azure reserves
# for its own system databases.
name = local.postgres_database_name
server_id = azurerm_postgresql_flexible_server.this.id
collation = "en_US.utf8"
charset = "UTF8"
}
# Lets Container Apps (and other Azure-internal callers) reach the flexible
# server without a per-deployment firewall rule. Tighten this to a VNet
# integration + private endpoint for stricter network isolation.
resource "azurerm_postgresql_flexible_server_firewall_rule" "azure_services" {
name = "AllowAzureServices"
server_id = azurerm_postgresql_flexible_server.this.id
start_ip_address = "0.0.0.0"
end_ip_address = "0.0.0.0"
}
# Optional Redis Cache, gated behind the `enable_redis_cache` flag — off by
# default so a plain deploy incurs no extra cost.
#
# This block creates the infrastructure. The cutover (azure-cutover.sh) sets
# AUTUMN_CACHE__BACKEND and AUTUMN_CACHE__REDIS__URL on the Container App
# (#2314). That alone does NOT make the app use Redis: Autumn's cache
# subsystem has no built-in Redis implementation (unlike sessions/channels/jobs, which activate from config
# alone once compiled with the `redis` Cargo feature). Your application must
# ALSO depend on the separate `autumn-cache-redis` crate and register
# `.plugin(RedisCachePlugin::new())` in main.rs — without that, these env
# vars are parsed into config and never read by anything, the app silently
# keeps its default in-process cache, and you pay for an Azure Redis
# instance that does nothing. See the "Shared Cache" section of
# docs/guide/cloud-native.md for the exact three steps.
resource "azurerm_redis_cache" "this" {
count = var.enable_redis_cache ? 1 : 0
name = "${local.app_name_alnum}-redis-${random_id.suffix.hex}"
resource_group_name = azurerm_resource_group.this.name
location = azurerm_resource_group.this.location
capacity = 0
family = "C"
sku_name = "Basic"
non_ssl_port_enabled = false
minimum_tls_version = "1.2"
}
# Stored in Key Vault only when Redis is enabled. The cutover gives it to
# the app as a secret ref, never as a plain env value.
resource "azurerm_key_vault_secret" "redis_url" {
count = var.enable_redis_cache ? 1 : 0
name = "AUTUMN-CACHE-REDIS-URL"
# Azure Redis access keys are base64-like and may contain "/" — raw in a
# URL's userinfo segment, a "/" would terminate the authority before
# "@hostname" and produce a malformed URL depending on the randomly issued
# key. urlencode() avoids that the same way database_admin_password is
# encoded in the derived database_url secret above.
value = "rediss://:${urlencode(azurerm_redis_cache.this[0].primary_access_key)}@${azurerm_redis_cache.this[0].hostname}:${azurerm_redis_cache.this[0].ssl_port}"
key_vault_id = azurerm_key_vault.this.id
depends_on = [azurerm_key_vault_access_policy.terraform]
}
resource "azurerm_container_app" "this" {
name = local.app_name_safe
container_app_environment_id = azurerm_container_app_environment.this.id
resource_group_name = azurerm_resource_group.this.name
revision_mode = "Single"
# No identity, registry or secret refs here (#2314). The public bootstrap
# image must not get production credentials. azure-cutover.sh attaches them
# from the migration job when it sets the real image.
# External ingress stays DISABLED until the first real image is deployed
# (#2312), so no inbound request wakes the placeholder. `min_replicas = 0`
# does not stop the HTTP scale rule below from starting it.
# azure-cutover.sh enables external ingress after the new revision runs
# the real image.
ingress {
external_enabled = false
target_port = 3000
transport = "http"
traffic_weight {
latest_revision = true
percentage = 100
}
}
template {
min_replicas = var.min_replicas
max_replicas = var.max_replicas
# Keep the app container first. The lifecycle block below refers to
# container[0], and azure-cutover.sh stops if the app container is not
# first. Add a sidecar after this block. A sidecar in this file must not
# refer to database-url, signing-secret or redis-url: Terraform does not
# manage these app secrets, and `azure-cutover.sh --without-redis`
# removes redis-url. Set such an env var with `az containerapp update`.
container {
name = local.app_name_safe
# Terraform only ever sets the bootstrap placeholder here — see the
# header comment. Real deploys land via azure-cutover.sh
# (azure-deploy.yml / the manual walkthrough in
# docs/guide/deployment.md), which the `lifecycle` block below stops
# a later `terraform apply` from reverting.
image = var.bootstrap_image
cpu = 0.5
memory = "1Gi"
# The startup probe holds the other probes until startup is complete.
# A slow first boot gets 100 seconds (10 x 10s).
startup_probe {
transport = "HTTP"
port = 3000
path = "/startup"
interval_seconds = 10
failure_count_threshold = 10
timeout = 3
}
# /ready gates traffic. Autumn sets it to 503 when a drain starts. When
# the probe fails, the platform stops new requests to the replica. One
# success is enough, so a scale-from-zero replica gets traffic quickly.
readiness_probe {
transport = "HTTP"
port = 3000
path = "/ready"
interval_seconds = 5
success_count_threshold = 1
timeout = 3
}
# A failed liveness probe restarts the replica. /live does not fail
# when a dependency, such as the database, is down.
liveness_probe {
transport = "HTTP"
port = 3000
path = "/live"
timeout = 3
}
env {
name = "AUTUMN_PROFILE"
value = "prod"
}
# Required: the prod profile fails fast at startup — the process
# never binds — when security.trusted_hosts.hosts is empty (host-
# header allow-list, see docs/guide/deployment.md#trusted-hosts-host-
# header-allow-list). Comma-separate additional entries here (e.g. a
# custom domain once one is bound) — the config parser reads this as
# a CSV list.
env {
name = "AUTUMN_SECURITY__TRUSTED_HOSTS__HOSTS"
value = local.app_fqdn
}
}
# Scale-to-N on concurrent HTTP requests — see the "Scale config
# defaults" note in variables.tf for min/max replica tuning.
http_scale_rule {
name = "http-concurrency"
concurrent_requests = 10
}
}
lifecycle {
# CI manages the real image after bootstrap (see the header comment);
# without this, `terraform apply` would revert every live deploy back
# to `var.bootstrap_image`. CI also opens external ingress at the first
# cutover (#2312); ignoring it here stops a later `terraform apply` from
# closing it again and taking the live app offline.
#
# The cutover also attaches the identity, registry, secret refs and
# secret env vars (#2314). Ignore them, or `terraform apply` removes
# them from the live app. Terraform sets env vars at create time only.
# To change one later, use `az containerapp update --set-env-vars`.
ignore_changes = [
template[0].container[0].image,
template[0].container[0].env,
ingress[0].external_enabled,
identity,
registry,
secret,
]
}
}
# One-shot migration job — mirrors the docker-compose target's dedicated
# `migrate` service. CI (azure-deploy.yml) starts this job with the new
# release image and waits for it to succeed *before* `az containerapp
# update` cuts the main app over, so a schema-dependent release never serves
# traffic against the old schema (the generated production config sets
# `auto_migrate_in_production = false` — no replica runs migrations on its
# own). `manual_trigger_config` means it only ever runs when CI explicitly
# starts it; Terraform itself never triggers it.
resource "azurerm_container_app_job" "migrate" {
name = "${local.app_name_safe}-migrate"
resource_group_name = azurerm_resource_group.this.name
location = azurerm_resource_group.this.location
container_app_environment_id = azurerm_container_app_environment.this.id
replica_timeout_in_seconds = 600
replica_retry_limit = 0
manual_trigger_config {
parallelism = 1
replica_completion_count = 1
}
identity {
type = "UserAssigned"
identity_ids = [azurerm_user_assigned_identity.this.id]
}
registry {
server = azurerm_container_registry.this.login_server
identity = azurerm_user_assigned_identity.this.id
}
# azure-cutover.sh copies these secret refs to the app (#2314). Keep every
# secret the app needs here. The identity can read the whole vault, so
# this does not give the job more access. Versionless IDs let the app get
# a rotated secret without a new deploy.
secret {
name = "database-url"
key_vault_secret_id = azurerm_key_vault_secret.database_url.versionless_id
identity = azurerm_user_assigned_identity.this.id
}
secret {
name = "signing-secret"
key_vault_secret_id = azurerm_key_vault_secret.signing_secret.versionless_id
identity = azurerm_user_assigned_identity.this.id
}
dynamic "secret" {
for_each = var.enable_redis_cache ? [1] : []
content {
name = "redis-url"
key_vault_secret_id = azurerm_key_vault_secret.redis_url[0].versionless_id
identity = azurerm_user_assigned_identity.this.id
}
}
template {
container {
name = "migrate"
image = var.bootstrap_image
cpu = 0.5
memory = "1Gi"
command = ["sh", "-lc", "autumn migrate"]
env {
name = "AUTUMN_DATABASE__PRIMARY_URL"
secret_name = "database-url"
}
}
}
depends_on = [
azurerm_role_assignment.acr_pull,
azurerm_key_vault_access_policy.container_app,
]
lifecycle {
ignore_changes = [template[0].container[0].image]
}
}