Hedronite · Ops Lesson · 01-Earth-DevOps / Terraform · Fri 2026-08-21 · Trio #96

Terraform Azure Storage Public Access — the output that is still the last apply

The URL did not move. The grant did. terraform output still prints Tuesday.

Lesson Class: Ops (DevOps + Terraform + Azure Storage)
Sprint: TF track · day 30 · tenth TF visit · trio #96
Cloud Referent: Azure Storage Account — public_network_access_enabled vs primary_blob_endpoint. Rebalances after AWS 08-18 and GKE 08-15.
Paired Dev: Terratest + Azure SDK GetProperties
Paired Cert: Pro-depth terraform test: plan run vs apply run
Grounding: Brikman Ch.3 pp.179-188 / Ch.9 pp.544-546 · Azure figure 9.32 · Lab 29 referenced
The string
primary_blob_endpoint is a hostname. It survives the grant.
The switch
public_network_access_enabled lives on the account. The plan can see it. The output updates on apply.
The consumer
terraform_remote_state reads the file. The file is last apply.
The URL did not move. The grant did. terraform output still prints Tuesday.

<!-- hal:authoritative:yaml -->

The URL did not move. The grant did. terraform output still prints Tuesday.

§I — Frame

Tuesday this arc locked an S3 backend and named the apply that wrote without the claim. Saturday bound a GKE ServiceAccount with a member Terraform computed and no author typed. 08-12 replaced an ASG in the right order. Today's overlay is Azure on purpose. The last three TF Ops visits sat on AWS, GCP, AWS. Azure Storage is the rebalance, and it is the account the Pro study-guide sample actually opens in chapter 09 figure 9.32.

You apply an azurerm_storage_account. You export primary_blob_endpoint. A consumer stack reads that output through terraform_remote_state. A week later you plan public_network_access_enabled = false. The plan is honest. The output is not. terraform output primary_blob_endpoint still prints https://stappprod.blob.core.windows.net/. The string did not change when the grant did. A consumer that refreshed during your plan still sees the last apply.

Coin it: the output that is still the last apply.

This is not 08-18. That lesson isolated writers of one state file. Today the isolation that matters is a property Azure stores on the account, not a string Terraform stored in state last Tuesday. This is not 07-31. That lesson isolated environments on a GCS prefix. The prefix is not the grant either.

Brikman, The terraform_remote_state Data Source, is blunt: the data source fetches the state file stored by another set of configurations, and the data is read-only. Stored state is applied state. A plan that has not been applied is invisible to every consumer.

§II — Foundations: four facts about the string and the switch

Fact one. The endpoint is a name, not a permission.

azurerm_storage_account always grows a public hostname. The provider computes primary_blob_endpoint, primary_file_endpoint, primary_queue_endpoint, primary_table_endpoint, and the matching DFS and web endpoints. They look like access. They are DNS. Azure will keep answering that name even after you shut public network access, because the name is how the private endpoint, the trusted-service exception, and the diagnostic path still find the account.

Figure 9.32 in the Azure sample is a minimal account:

resource "azurerm_storage_account" "backup" {
  name                     = "stmyterraformstorageaccount"
  resource_group_name      = azurerm_resource_group.default.name
  location                 = azurerm_resource_group.default.location
  account_tier             = "Standard"
  account_replication_type = "LGS"
}

The sample is broken on purpose (LGS is not a replication type; LRS is). The broken field is not today's lesson. The resource is. Once the typo is LRS, the first apply writes an account and a pile of computed endpoints. None of those endpoints mention public_network_access_enabled. If you want that property in an output, you must write the output. Most modules do not.

Fact two. The switch lives on the account, and the plan can see it.

azurerm 4.x names the switch public_network_access_enabled. Set it false and the next plan shows an in-place update on the account. The blob endpoint attribute does not appear in the diff. Terraform is not hiding a change. There is no change to hide. The URL is the same object.

A second switch, allow_nested_items_to_be_public, governs whether a container or blob can still be marked public when the account itself allows public network access. Turning the account switch off is the outer door. Leaving the nested switch on while the outer door is open is how a "private" module still serves a container to the internet. Neither switch is in primary_blob_endpoint.

network_rules is the third door: default action, bypass for AzureServices, ip_rules, virtual_network_subnet_ids. A plan that adds a deny-by-default rule also leaves the endpoint string alone.

Fact three. A plan reads. An apply writes. Outputs update on write.

Brikman, Plan testing (Ch.9, PDF pp.544-546): plan executes the read steps (fetching state, executing data sources) and not the write steps (creating or modifying resources). The ALB snippet he prints makes the split visible. load_balancer_type = "application" is known. arn = (known after apply) is not. After the first apply, terraform output can print the ARN. After a later plan that would replace the ALB, terraform output still prints the old ARN until someone applies.

The same split is the Azure account. primary_blob_endpoint became known on the first apply. A later plan that flips public_network_access_enabled does not rewrite the output. terraform output is a read of applied state. It is not a preview of the plan.

Fact four. A consumer reads applied state, and only applied state.

data.terraform_remote_state is Brikman's answer to a web cluster that must not live in the same root module as its database. The data source points at the same backend the producer used, and it returns the producer's outputs. "Like all Terraform data sources, the data returned by terraform_remote_state is read-only." Nothing the consumer does updates the producer. Nothing the consumer can do sees the producer's unapplied plan.

So a network stack that exports the storage account id and the blob endpoint, and an app stack that reads both, have a contract: the app stack sees Tuesday. If you planned the public-access flip on Wednesday morning and have not applied, the app stack still builds against a public account. If you applied and the app stack has not refreshed, it still builds against Tuesday's outputs. Refresh is a read. It is not a time machine for a plan you have not written.

§III — Mechanism: three places the last apply hides

**Place one. terraform output in the producer.**

You changed the .tf. You have not applied. terraform output public_network_access (if you even exported it) still prints true. terraform output primary_blob_endpoint still prints the public URL. Operators treat terraform output as "what is the module." It is "what did the last apply write."

**Place two. terraform_remote_state in the consumer.**

The consumer's plan runs data sources. That is a read of the producer's state object. Brikman's Figure 3-11 is the picture: the database writes state to a bucket, the web cluster reads that state from the same bucket. The arrows are write-then-read of a file, not subscribe-to-a-plan. HCP speculative plans do not chain; 08-15 already named that. Local plans do not chain either. The file is last apply.

**Place three. A check block or a data source that talks to Azure.**

data "azurerm_storage_account" "live" {
  name                = azurerm_storage_account.backup.name
  resource_group_name = azurerm_storage_account.backup.resource_group_name
}

check "public_access_is_off" {
  assert {
    condition     = data.azurerm_storage_account.live.public_network_access_enabled == false
    error_message = "Account still accepts public network access."
  }
}

The data source is a read against Azure, not against Terraform state. It sees the live switch. It still sees last apply, because Azure itself has not been written until you apply. The difference is honesty. The check fails after a successful apply that left the door open. terraform output primary_blob_endpoint never fails. The URL is always well-formed.

A private endpoint does not change this. azurerm_private_endpoint grows private_service_connection[0].private_ip_address after apply. That IP is a new output. The blob endpoint string remains the public hostname. Clients that honor the private DNS zone resolve the same hostname to the private IP. Clients that do not, or that run from a network that never received the zone, still hit the public name. Whether that name answers is the switch, not the string.

§IV — Worked Example: the account, the flip, the consumer that slept

Two root modules. storage/ owns the account. app/ reads it.

storage/outputs.tf after the first apply:

output "account_id" {
  value = azurerm_storage_account.backup.id
}

output "primary_blob_endpoint" {
  value = azurerm_storage_account.backup.primary_blob_endpoint
}

output "public_network_access_enabled" {
  value = azurerm_storage_account.backup.public_network_access_enabled
}

The third output is the one most modules skip. Keep it. It is still last apply. It is at least a last-apply of the switch, not of the URL.

app/ consumes:

data "terraform_remote_state" "storage" {
  backend = "azurerm"
  config = {
    resource_group_name  = "rg-tfstate"
    storage_account_name = "sttfstateprod"
    container_name       = "tfstate"
    key                  = "storage/prod/terraform.tfstate"
  }
}

locals {
  blob = data.terraform_remote_state.storage.outputs.primary_blob_endpoint
}

Sequence.

  1. Monday: apply storage/ with public_network_access_enabled = true. Outputs: endpoint https://stappprod.blob.core.windows.net/, switch true.
  2. Monday: apply app/. Consumer stores the endpoint in its own state.
  3. Wednesday: edit storage/ to public_network_access_enabled = false. Run plan. Plan shows an in-place update on the account. terraform output in storage/ still prints true and the same URL.
  4. Wednesday: apply app/ again, because a tag changed. Consumer refresh reads storage/ state. Switch still true. The URL is still the public name. app/ is correct about Tuesday and wrong about Wednesday.
  5. Wednesday later: apply storage/. Azure shuts the public door. terraform output in storage/ now prints false. The URL is unchanged.
  6. Thursday: app/ has not refreshed. Its state still holds Tuesday's outputs. A human reading terraform output in app/ reports the account is public.

The failure at step 4 is the one teams miss. They planned the flip, they deployed the consumer, they believed the consumer saw the plan. The consumer saw the file.

The failure at step 6 is slower. Applied state in the producer moved. Applied state in the consumer did not. terraform_remote_state updates when the consumer plans or applies, not when the producer does.

A private endpoint added in the same Wednesday apply does not rescue the consumer. The consumer that only reads primary_blob_endpoint still has a URL. It does not have the private IP, the zone, or the connection state. Those are other outputs, and they are also last apply.

The azurerm backend is the same shape, and it is not today's lock lesson.

storage/ and app/ in the example both point at an azurerm backend: a storage account, a container, a key. 08-18 spent a day on S3 plus DynamoDB LockID. Azure's equivalent is a blob lease on that state object. Hold the lease. Release the lease. Do not force-break a live apply. None of that is the grant on the workload account. Teams collapse the two accounts in conversation ("the storage account") and then debug the wrong one. The state account can be private and locked while the workload account is still public. The output that names the workload endpoint will not tell you which account you hardened.

Partial backend config still applies. The backend block does not evaluate variables. key is a literal. Isolation of state files is the key path, the same rule Lab 04 and Lab 31 teach on S3. Isolation of access to the workload account is public_network_access_enabled plus network_rules plus the private endpoint. Three isolations, three objects. Mixing them is how a private-state shop still ships a public blob endpoint in app/ locals.

What a check block can and cannot do here.

A check that reads azurerm_storage_account.backup.public_network_access_enabled reads the configuration graph, not Azure. After you edit the .tf and before you apply, that expression already says false, and the check can pass against a live account that is still public. Invert the instinct. The configuration is the plan. The data source is the world. Use the data source when the claim is "Azure has shut the door." Use the resource attribute when the claim is "this module would shut the door if applied." Lab 07 taught that split as validate / precondition / check. Today's use is narrower: the check is a live read, or it is another last-apply.

Bootcamp figure 9.32 never sets the public-access switch. A first apply of that sample (once LGS becomes LRS) creates a public account. The endpoint looks finished. The grant is the default, which is open. That is the sample's silent lesson, next to the typo it actually teaches.

§V — Connection to Prior Lessons

08-18 taught the lock. Two applies, one claim, and the stranger who wrote without holding it. Today's stranger is quieter. Nobody raced. Nobody passed -lock=false. The producer planned a tighter account. The consumer applied against the old file. The lock was held the entire time.

08-15 taught a binding Terraform computed and no author typed. The GKE member string is a computed value that becomes true at apply. Today's endpoint is also computed at apply. The difference is persistence. The member changes when the binding changes. The blob endpoint does not change when the grant changes. Computed is not the same as current.

08-12 taught a change you can only witness during the apply. The ALB either stays up or it does not, and the proof is traffic in the window. Today's change has no window. The URL is stable. The grant flips in one API call. The witness is Azure after the call, or it is a lie.

07-31 isolated environments on a GCS prefix. A different prefix is a different file. Today's consumer and producer already have different files. The remaining lie is inside one file: an output that survived the meaning of the resource.

§VI — Connection to Today's Dev Lesson

The Dev lesson takes the same account and refuses to trust terraform.Output. Terratest can print primary_blob_endpoint in one line. That line is last apply, and it is the same string on both sides of the flip. The check that can fail is AccountsClient.GetProperties on the live account, asserting PublicNetworkAccess == Disabled.

07-31 built the test: options, deferred destroy, retries, stages. 08-12 taught a goroutine that watches during the apply. Today's fire is the third terratest visit and the first one that leaves Terraform's outputs and talks to the cloud SDK. That is the unused clause in the runbook triad: "SDK integration for resource-state verification."

If the Dev test only asserts the endpoint string, it will pass on a public account. That is the same lie as terraform output in this lesson, compiled into Go.

§VII — Closing

The blob endpoint is a hostname. The grant is a property. Outputs update when you apply. Consumers read files, not plans. A check that only prints the hostname cannot see the door.

Name it when you see it. The output that is still the last apply. Plan the flip. Apply the producer. Refresh the consumer. Then ask Azure.

Examine well. The URL will still be pretty. The door is the property Azure stores on the account, and only an apply writes that property.

Related

🫡 ⚖️ 📜 Leo.Syri — Praetor Consulate, Imperium Luminaura Filed 2026-08-21 · Fajr · sprint track TF day 30 · tenth TF visit · trio #96

🫡 ⚖️ 📜
Leo.Syri — Praetor Consulate, Imperium Luminaura
Filed 2026-08-21 at Fajr · Trio #96 · sprint day 30 · TF track tenth visit
Ops · Dev · Cert trio shipped MD + HTML in-cycle