The image tag problem, in Bicep and in Terraform

30 July 2026 · 7 min read

This site runs as one container in Azure Container Apps. Until recently every Azure resource behind it was created by hand, from az commands pasted out of a deployment guide, and the pipeline did exactly one thing on each push: swap the image on an app it assumed already existed.

Replacing that with infrastructure as code turned up a trap that has nothing to do with which tool you pick.

The trap

The platform's natural deployment step is:

az containerapp update --name web --image ghcr.io/me/site:sha-c3d4

Infrastructure as code, meanwhile, wants to declare the whole resource — including the image, because the image is part of what the app is. So the tag ends up written in two places, and only one of them is current.

Two pipelines: in the first, the image tag appears in both the template and the CI update command, and the next template apply reverts the app to the older tag. In the second, the template takes the tag as a parameter, so applying the template is the deploy.

The failure mode is quiet, which is what makes it worth writing down. Nothing breaks at deploy time. The tag in the template is simply stale, so the next time you touch infrastructure for an unrelated reason — bumping a memory limit, adding a probe — the deployment faithfully reverts production to whatever tag the file happens to name. You changed the memory limit; you also rolled back three weeks of releases.

There are three ways out.

Pin the tag in the template and let CI keep swapping the image. This is the smallest diff and the one that feels most natural when the pipeline already works. It is also guaranteed drift: the file is wrong between every pair of deploys, and its wrongness is load-bearing. Rejected.

Tell the tool to ignore the imageignore_changes in Terraform, or omit the container and patch it afterwards. This trades a silent lie for a visible hole: the single most important field in the resource becomes undeclared, and "infrastructure as code, except the part that changes" is not a description worth defending.

Make the tag a required parameter. Applying the template is the deploy. CI passes sha-<commit>, the file is always accurate, and re-applying is safe by construction. It removes a pipeline step rather than adding one.

The third option is obvious in hindsight, and it is not where most people start, because the platform's own documentation leads with containerapp update. The tooling nudges you into the trap.

The same fix in two tools

In Bicep the parameter has no default, so a deployment cannot silently pick a tag:

@description('Fully qualified image including tag. CI passes ghcr.io/<owner>/<repo>:sha-<commit>.')
@minLength(3)
param image string

The awkwardness is on the CI side. A .bicepparam file does not compose cleanly with inline --parameters overrides, so the tag arrives through the environment:

param image = readEnvironmentVariable('CONTAINER_IMAGE', 'ghcr.io/OWNER/REPO:latest')

That fallback is deliberately invalid. A manual deployment that forgets to export the variable fails on an image pull, which is a good failure — loud, immediate, and impossible to mistake for success. The alternative was defaulting to :latest, which would deploy something and leave you guessing what.

Terraform takes the value directly, which lets the validation be sharper:

variable "image" {
  type = string

  validation {
    condition     = can(regex(":", var.image))
    error_message = "image must include an explicit tag; an untagged reference would silently mean :latest."
  }
}

Bicep cannot express that without a regex over a string parameter. It is a small win, but characteristic: Terraform's input validation is more expressive, and you notice it in exactly these small places.

Keeping both, and what that costs

I wrote both definitions to decide between them, then kept both — a worked comparison against one real deployment is more useful than either alone.

That is only defensible with a rule: exactly one definition is applied. Two things reconciling the same resources would fight, last write wins. Bicep applies, because the state backend Terraform needs must itself be bootstrapped by hand, and a second role assignment on a storage container outside the resource group is real cost for a site this small.

Which leaves the actual problem. The Terraform definition is never applied, so it has no deployment to catch its mistakes. An unapplied definition rots, and a comparison that has quietly stopped being true is worse than no comparison at all.

Both definitions are compiled and validated on every pull request, and a parity script compares the values that must agree across them. On merge, only the Bicep definition is applied to Azure; the Terraform one stops at CI.

So parity is enforced rather than trusted. A shell script compares the values a reader would compare — replica counts, CPU and memory, port, log retention and quota, all three probe thresholds, the probe path, and the output names, normalised across camelCase and snake_case — and fails the build on divergence:

  minReplicas default                0
  startup failure threshold          20
  probe path                         /healthz
  output names                       containerappname customdomainverificationid ...

Bicep and Terraform definitions agree.

Fifteen checks, and it does fail: change a probe threshold in one file, rename an output in the other, and the gate reports both and exits non-zero. A gate nobody has watched fail is decoration.

The honest cost is that every infrastructure change now has to be made twice, in two languages, or CI rejects it. That is paid deliberately here, for the explanatory value. On a team shipping features it would be an obviously bad trade, and the right move would be to delete one directory and the gate along with it.

This is the same approach the repository already takes to its layering rules — the build greps for architecture violations rather than trusting a convention. Enforcement is cheap; documentation that drifts is not.

What declaring it actually bought

Not reproducibility, or not mainly. This app has never needed rebuilding from scratch, and if it had, the guide would have worked.

What it bought was seeing the defaults. Writing the resources out longhand surfaced two things that had been invisible.

az containerapp env create quietly provisions a Log Analytics workspace. Nobody chose its retention, and nobody chose its ingestion quota, so there wasn't one — an unbounded billing surface on a project whose first stated constraint is a zero bill. It is now 30 days with a 1 GB/day cap, and those numbers sit in a file somebody can argue with.

The second was worse. An architecture decision record for this site claims "a health endpoint backs the platform probes." The /healthz endpoint exists. No probe had ever been configured, so the platform default applied: a TCP check that passes the moment Kestrel binds the port — before a single post has been parsed. A container that started but could not serve a page would have taken traffic. The document had been wrong since the day it was written, and declaring the infrastructure is what caught it.

That is the argument for infrastructure as code at small scale, and it is not the usual one. Reproducibility barely matters with one environment. Being forced to write down every value, including the ones a CLI was choosing for you, matters a lot.

What is still not code

Three things stayed manual, on purpose.

The resource group and the deploy credential. The service principal is scoped to a single resource group, so a leaked credential cannot reach the rest of the subscription. Declaring the group would require the pipeline to hold subscription-level rights — trading a real security boundary to eliminate one az group create. A credential also cannot create itself.

Custom hostnames and managed certificates. Azure will not issue a certificate until the hostname's DNS records resolve, so a declarative binding must fail on first apply and succeed on a second. Encoding a known-failing first run into the pipeline is worse than the documented manual step it would replace. The templates do the useful half instead: the environment's static IP and the domain verification ID come out as deployment outputs, so the values the DNS setup needs fall out of the deploy rather than needing three more az queries.

"Everything is code" was available both times. It would have been worse both times.