Why rebuild what already worked
The environment already existed. It had been assembled through the Azure portal over several sessions — a Container App here, a Key Vault there, a role assignment added when something failed. It worked, and it had three problems.
It was not reproducible: nobody, including the person who built it, could recreate it from scratch without retracing every click. It could not be reviewed, because there was nothing to read. And it could not be cheaply destroyed, because rebuilding it was an afternoon's work — which meant the PostgreSQL server ran continuously, billing by the hour, whether or not anyone was using it.
Rebuilding it in Terraform fixed all three. The environment now
describes itself, the description lives in version control alongside the
reasoning for each decision, and terraform destroy is a
reasonable thing to do at the end of a session.
Building it by hand first was not wasted effort. Writing Terraform for infrastructure you already understand is considerably easier than learning both at once — the portal work established what each resource does and why it is there, leaving only the syntax to learn.
What it provisions
A resource group containing a Key Vault, a PostgreSQL Flexible Server with its database and firewall rules, a Container Apps environment and the Container App itself, and the role assignments that connect them.
The dependency graph is built implicitly. Resources reference each other's attributes rather than hardcoded strings, which is what tells Terraform the ordering:
resource "azurerm_key_vault" "main" {
name = "kv-${var.project}-${var.environment}-${random_string.kv_suffix.result}"
resource_group_name = azurerm_resource_group.main.name
location = azurerm_resource_group.main.location
...
}Writing the resource group name as a literal string would work, and would silently discard the ordering guarantee. This is the central idiom of the tool and the one worth internalising first.
The connection string is composed, not stored
The database URL written into Key Vault is assembled in Terraform from the server's own FQDN, the database name, and the credential variables. It is never typed out anywhere. If the server is recreated under a new name, the secret updates automatically and the application picks it up on its next container start.
value = format(
"postgresql+psycopg://%s:%s@%s:5432/%s?sslmode=require",
var.postgres_admin_username,
var.postgres_admin_password,
azurerm_postgresql_flexible_server.main.fqdn,
azurerm_postgresql_flexible_server_database.shortener.name,
)The credential path
The most interesting thing this configuration builds is not a resource. It is a chain of trust that lets a running container read a database password without any credential existing in the repository, the image, or the platform configuration.
-
Terraform
Write the secret
The composed connection string is stored in Key Vault. An explicit
depends_onensures the operator's own Secrets Officer role assignment exists first — Terraform can infer that the secret needs the vault, but not that the caller needs permission to write to it. -
Entra ID
Create an identity
The Container App is declared with a system-assigned managed identity. Azure creates a service principal bound to that app's lifecycle — created with it, deleted with it, not shared, with no password or key stored anywhere.
-
Key Vault
Grant read access
That identity is assigned the Key Vault Secrets User role, scoped to this vault. Read-only, and distinct from the Secrets Officer role the operator holds. Access is granted through Azure RBAC rather than the legacy vault access policy model.
-
Container Apps
Resolve at startup
The app's
DATABASE_URLis a Key Vault reference, not a literal. When a container starts, the platform authenticates with the managed identity, retrieves the current secret version, and injects the value into the process environment. -
PostgreSQL
Connect over TLS
The application opens a pooled connection with
sslmode=require. Azure refuses unencrypted connections, and omitting the parameter produces a generic error that never mentions TLS.
Repository structure
shortener-infra/
├── versions.tf Terraform and provider version constraints
├── variables.tf Declares inputs — committed
├── main.tf Resource definitions
├── outputs.tf Values consumed by tooling
├── example.tfvars Documents required values, stripped
├── terraform.tfvars Real values — git-ignored
├── terraform.tfstate State — git-ignored, secrets in plain text
├── .terraform.lock.hcl Pinned provider versions — committed
└── scripts/
└── bootstrap.sh Applies database migrations after an applyInfrastructure lives in its own repository, separate from the application. Different lifecycles, different review requirements, and a change to a Caddy route or a Python handler has no business triggering an infrastructure plan.
What must never be committed
The state file records every attribute of every resource, including
the database password, in plain text. Marking a variable
sensitive suppresses it from plan output and from
terraform output — it does not encrypt it and does not keep
it out of state. *.tfvars and *.tfstate are
both ignored, and history was searched with git log -S before
the first push, which catches values that were added and later
removed.
.terraform.lock.hcl is committed despite the similar
name. It pins exact provider versions so that a plan run today and a plan
run next month produce the same result.
Decisions worth explaining
Purge protection disabled on the Key Vault
Key Vault soft-deletes by default. With purge protection enabled, a
destroyed vault continues to occupy its name for the retention period and
cannot be force-purged — which means terraform destroy
followed by terraform apply fails on a name collision.
Disabled here because this environment is rebuilt frequently and holds nothing irreplaceable. It is precisely the wrong setting for anything real, which is why the reasoning is a comment in the configuration rather than a silent default.
A random suffix on globally unique names
Key Vault names are unique across all of Azure, so an obvious name is
almost certainly taken. A random_string resource generates a
suffix that is stored in state and therefore stable across applies — but
it changes after a destroy, since the random resource is destroyed too.
Worth knowing before anything else starts depending on the name.
Ignoring availability zone drift
Azure sometimes reports a PostgreSQL availability zone differing from
what Terraform recorded, producing a plan that proposes destroying and
recreating the server. A lifecycle block ignoring that
attribute suppresses it. Escape hatches like this are worth knowing and
worth using sparingly — they hide genuine drift as readily as noise.
Terraform stops at the database boundary
Terraform provisions the server; it does not manage what is inside it. Schema is applied separately by Alembic, through a small script that reads the vault name from a Terraform output, retrieves the connection string, and runs the migration.
That boundary is deliberate rather than a gap. Folding schema
management into infrastructure applies would gate every migration behind
an infrastructure change and make terraform destroy a
data-loss event in anything resembling production.
Forgetting the migration step produces a memorable
symptom: /health returns OK, because it only runs
SELECT 1, while every other endpoint returns a 500. The
application is connected to a database with no tables in it.
Recovering from a partial apply
An apply failed partway through. The Container App resource was rejected by Azure because the image reference had been supplied as a bare tag rather than a fully qualified path, so the platform defaulted to Docker Hub's official-images namespace and reported an authentication failure for an image that does not exist.
Correcting the variable and re-running produced a different error:
Error: a resource with the ID "/subscriptions/.../containerApps/ca-shortener-dev"
already exists - to be managed via Terraform this resource needs to be
imported into the StateThe first apply had in fact created the Container App. It failed afterwards, when the revision could not pull its image. Terraform saw a failed operation and did not record the resource, so state and reality had diverged: Azure held a resource that Terraform believed did not exist.
terraform import binds an existing resource to its
Terraform address, after which the next plan proposed only the image
correction. The alternative — deleting the orphan and re-applying — is
faster but teaches nothing.
What this is actually about
Terraform is not transactional. It makes API calls one at a time, and a failure partway through leaves real resources behind that state does not know about. This is expected behaviour rather than a defect, and it is the reason a failed apply is always followed by a plan rather than by immediately re-running apply.
It also argued for input validation. A malformed image reference caught
at plan time costs nothing; caught at apply time it cost a half-created
resource and a state repair. A validation block on the
variable now rejects anything without a registry and a tag.
Known limitations
State is local. A file on disk, git-ignored, holding the database password in plain text. It is not backed up, not locked against concurrent applies, and lost with the machine. Remote state in Azure Storage — encrypted, versioned, with lease-based locking — is the correct answer and the next thing to change.
The database firewall is broader than it should be.
The 0.0.0.0 rule is Azure's special case meaning "allow any
Azure service", which permits connections from any tenant rather than
just this one. VNet integration with a private endpoint is the proper
fix, and it is a networking exercise rather than a one-line change.
The image tag is a variable, updated by hand. Automating deployment needs OIDC federation between GitHub Actions and Entra ID — conceptually the same move as the managed identity above, and for the same reason: stop storing credentials and let the platform vouch for identity.
Migrations cannot currently run in CI, blocked by the firewall. A hosted runner is neither the allowlisted admin IP nor an Azure service. Solving it properly means solving the networking first, which is a good illustration of how one deferred decision constrains a later one.
There is one environment. No dev, staging and production separation. Workspaces or per-environment directories would be the next structural step.
Stack
- Tooling
- Terraform, azurerm and random providers, Azure CLI
- Compute
- Azure Container Apps, Container Apps environment, KEDA scale to zero
- Data
- Azure Database for PostgreSQL Flexible Server, Alembic migrations
- Identity
- Entra ID, system-assigned managed identity, Azure RBAC, Key Vault
- Observability
- Log Analytics, KQL, Container Apps system and console logs