# 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
# `az containerapp update`/`az containerapp job start --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.
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 provisions the infrastructure and wires AUTUMN_CACHE__BACKEND /
# AUTUMN_CACHE__REDIS__URL into the Container App, but that alone does NOT
# make the app use it: 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 — mirrors how the database
# URL and signing secret are bound in as Container App secret refs, never as
# plain env values.
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"
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
}
secret {
name = "database-url"
key_vault_secret_id = azurerm_key_vault_secret.database_url.id
identity = azurerm_user_assigned_identity.this.id
}
secret {
name = "signing-secret"
key_vault_secret_id = azurerm_key_vault_secret.signing_secret.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].id
identity = azurerm_user_assigned_identity.this.id
}
}
ingress {
external_enabled = true
target_port = 3000
transport = "http"
traffic_weight {
latest_revision = true
percentage = 100
}
}
template {
min_replicas = var.min_replicas
max_replicas = var.max_replicas
container {
name = local.app_name_safe
# Terraform only ever sets the bootstrap placeholder here — see the
# header comment. Real deploys land via `az containerapp update
# --image ...` (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"
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
}
env {
name = "AUTUMN_DATABASE__PRIMARY_URL"
secret_name = "database-url"
}
env {
name = "AUTUMN_SECURITY__SIGNING_SECRET"
secret_name = "signing-secret"
}
# Autumn's cache config is `[cache] backend` + `[cache.redis] url`
# (env: AUTUMN_CACHE__BACKEND / AUTUMN_CACHE__REDIS__URL — note the
# double underscore before URL, matching the nested `cache.redis.url`
# TOML path). Without the backend switch, Autumn stays on its default
# in-memory cache and never reads the URL at all, silently ignoring
# this (paid) Redis instance.
dynamic "env" {
for_each = var.enable_redis_cache ? [1] : []
content {
name = "AUTUMN_CACHE__BACKEND"
value = "redis"
}
}
dynamic "env" {
for_each = var.enable_redis_cache ? [1] : []
content {
name = "AUTUMN_CACHE__REDIS__URL"
secret_name = "redis-url"
}
}
}
# 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
}
}
# Both must exist before Azure creates the app's first revision: ACR pull
# so the image can be pulled, and the Key Vault access policy so the
# secret refs above resolve (neither is otherwise referenced by attribute,
# so Terraform wouldn't infer the ordering on its own).
depends_on = [
azurerm_role_assignment.acr_pull,
azurerm_key_vault_access_policy.container_app,
]
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`.
ignore_changes = [template[0].container[0].image]
}
}
# 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
}
secret {
name = "database-url"
key_vault_secret_id = azurerm_key_vault_secret.database_url.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]
}
}