DEV Community

Daniel Romitelli
Daniel Romitelli

Posted on • Originally published at craftedbydaniel.com

Created Is Not Ready: Terraform Ordering vs. Azure Eventual Consistency

The deploy finished clean. Every resource reported created. The application died on startup anyway.

The failures were never dramatic. A service asked for a secret and got told no. A database existed but its private address would not resolve yet. Nothing in the application code was wrong. The cloud had said yes to creating everything, and I had been treating yes as ready.

I built the Azure infrastructure for a recruitment platform around the gap between those two words: more than 56 resources across three environments, 12 private endpoints, 7 private DNS zones, 43 role assignments, every platform service closed to the public internet. At that size the gap between created and ready is not a corner case. It is most of the job.

1. Two different promises

Terraform makes one promise and keeps it: nothing gets created before the things it references. Output references and explicit depends_on build a dependency graph (a DAG, directed and acyclic) and the apply walks it in order. I leaned on that everywhere.

Azure's promise is smaller than it looks. When the API accepts a resource, the control plane has written it down. That is all. Role-based access control (RBAC) assignments take time to reach the services that enforce them. A private endpoint can exist while the Domain Name System (DNS) record pointing at it has not settled. A vault can be live while the identity that needs it is still a stranger to the authorization checks.

depends_on buys you creation order. Nothing else. Missing that distinction is how a green apply produces a dead platform.

2. The build spine

I treated the root infrastructure as a spine: identity, networking, observability, secrets, data services, artificial intelligence model deployments, compute, edge routing, and policy. Each part produced outputs the next one consumed.

flowchart TD
 identity[Managed identities] --> rbac[Key Vault role assignments]
 identity --> network[Virtual network and subnets]
 network --> dns[Private DNS zones]
 rbac --> vault[Key Vault]
 network --> data[PostgreSQL, Redis, and Service Bus]
 dns --> privatelink[Private endpoints and DNS zone groups]
 data --> privatelink
 vault --> compute[Container Apps and workers]
 privatelink --> compute
 identity --> compute
 compute --> edge[Front Door and Web Application Firewall]
 edge --> policy[Policy enforcement]
Enter fullscreen mode Exit fullscreen mode

The diagram matters because plenty of Azure resources are valid as declarations long before they are useful as capabilities.

Identity went first. Managed identities have to exist before RBAC can grant them vault access, and vault reads sit directly on the startup path: the service reads a vault URL from the environment, builds a Key Vault client with the default Azure credential chain, and fills in missing variables from named secrets. Values already present in the environment still win, which keeps local development plain while production centralizes credentials. The price is a stricter startup. If cloud identity is wrong, the service dies early instead of limping forward half-configured.

Networking went in early but finished late. Virtual network, subnets, network security groups, and the private DNS zones came up front. The private endpoints could not, because they attach to services that did not exist yet. So the working arrangement ran: network and DNS foundations, then PostgreSQL, Redis, and Service Bus, then each service's endpoint and zone records, and only then Container Apps. A private DNS zone does not permit a database to be created. It lets consumers resolve the private address once the endpoint is attached (a distinction that sounds pedantic right up until a hostname will not resolve). Giving name resolution its own place in the spine made those failures easy to isolate.

Observability came before anything noisy: Application Insights, Log Analytics, and alert rules ahead of secrets, data, models, and compute. That delayed the first visible milestone. It also meant that when a container could not read a secret or a connection pool exhausted, the event landed in shared telemetry from the very first attempt. Retrofitted monitoring leaves gaps exactly where a new system teaches you the most.

Compute and the edge closed it out. Container Apps and workers started only after identities, secrets, private data access, messaging, caching, model endpoints, and telemetry could hold their weight. Front Door and the Web Application Firewall (WAF) sat behind that, since routing only matters once there is a stable backend, and policy enforcement came last, once the resource graph was visible enough to govern. The first public URL shows up late this way. I will take that delay over debugging application, network, identity, and routing faults at the same time.

3. What the references actually enforce

The implementation stayed boring on purpose. Root modules followed the spine, and later modules consumed outputs from earlier ones instead of rediscovering names or IDs. Identity outputs fed vault access assignments and compute identity settings. Network outputs fed endpoint subnet placement. Data service outputs fed connection settings. Pass capabilities forward, not strings sideways.

Those references, plus depends_on at module boundaries, enforced creation order. Full stop. They did not make RBAC propagation finish. They did not make a fresh role assignment visible to the vault's authorization checks, and they did not make a private hostname resolvable.

4. What readiness required on top

Readiness needed its own machinery, and none of it lives in the graph.

The bootstrap treats an authorization denial on a fresh deployment as possibly temporary. A role assignment can be accepted and not yet enforced, so the service retries with backoff before giving up. When configuration genuinely cannot be assembled, the container exits. Running half-configured is worse than not running, and the platform restart policy becomes a cheap outer retry loop while propagation catches up.

Between stages, the pipeline measured instead of assumed: can a secret actually be read, does the private hostname actually resolve. Only then did the next stage apply. And nothing became routable at the edge until the backend had proven it could reach its own dependencies.

None of this is exotic. It is the admission that the apply finishing is a checkpoint, and the platform being alive is something you verify afterward.

5. Cost by environment

The monthly figures are infrastructure forecasts from the design baseline, covering core platform services rather than labor or third-party application fees.

Environment Monthly infrastructure forecast Intent
Development Around $200 Keep the full shape affordable
Staging Around $400 Exercise integration paths before release
Production Around $870 Run with high availability

Parity is useful until it becomes waste. I kept the shape consistent across environments, then varied scale and availability.

6. The ledger and the measurement

Terraform state is a ledger. It records what has been requested and written down, and it holds no opinion about whether any of it works yet.

Every system I have built since runs on the same rule: creation is a request, readiness is a measurement, and nothing downstream starts until the measurement says so.


๐ŸŽง Listen to the audiobook โ€” Spotify ยท Google Play ยท All platforms
๐ŸŽฌ Watch the visual overviews on YouTube
๐Ÿ“– Read the full 13-part series

Top comments (0)