Infrastructure as code

The Azure environment behind the URL shortener, rebuilt declaratively in Terraform. Nine resources, one command, and an environment that can be destroyed between sessions and recreated identically — which turned a continuously-billing database into something that only exists while it is being used.

Source on GitHub  ·  Application in shortener

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.

  1. Terraform

    Write the secret

    The composed connection string is stored in Key Vault. An explicit depends_on ensures 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.

  2. 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.

  3. 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.

  4. Container Apps

    Resolve at startup

    The app's DATABASE_URL is 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.

  5. 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.

The secret reference deliberately omits the version, so rotating the password is a single vault update with no redeployment. Pinning the version would have defeated most of the benefit.

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 apply

Infrastructure 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 State

The 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