Why you end up with several states
The platform team's network lives in one state; your app lives in another (13.1 argued for that split). Now your app's VM needs the subnet ID - which only the network state knows. And next quarter a subnet has to move from the network state into yours, without being destroyed on the way.
Split states raise exactly these two questions:
- How does one configuration use something another one manages?
- How do you move an object from one state to another without destroying it?
What you need to know already:
- outputs and data sources (12.5, 12.18)
- the azurerm backend settings (13.4)
- import blocks (13.25) and
state rm(13.15) - hub and spoke networks (12.22)
Reading another state's values
Option 1: terraform_remote_state
The network configuration publishes outputs:
# network/outputs.tf
output "subnet_ids" {
value = { for k, s in azurerm_subnet.this : k => s.id }
}
That for expression (12.11) builds a map from subnet key to subnet ID.
The app configuration reads them with a special data source, terraform_remote_state. It takes the same settings as the other state's backend block, and exposes that state's outputs under .outputs:
data "terraform_remote_state" "network" {
backend = "azurerm"
config = {
resource_group_name = "rg-tfstate"
storage_account_name = "sttfstatesysop"
container_name = "tfstate"
key = "network/dev.tfstate"
}
}
resource "azurerm_linux_virtual_machine" "app" {
# ...
subnet_id = data.terraform_remote_state.network.outputs.subnet_ids["app"]
}
What to know about it:
- Only the other state's top-level outputs are visible - not its resources, and not values it keeps inside modules without re-exporting them.
- The reader needs read access to the whole state blob, which contains every attribute of every resource in it, secrets included. Giving the app pipeline read on the network state gives it all of the network state's secrets.
- It ties the two configurations tightly: renaming an output breaks the reader's next plan.
If the state does not exist yet (network not applied, wrong key), plan fails:
│ Error: Unable to find remote state
│
│ No stored state was found for the given workspace in the given backend.
Option 2: data sources on the real objects
Instead of reading the other team's state, look the subnet up in Azure by name (12.18):
data "azurerm_subnet" "app" {
name = "snet-app"
virtual_network_name = "vnet-spoke-orders-dev"
resource_group_name = "rg-network-dev"
}
resource "azurerm_linux_virtual_machine" "app" {
subnet_id = data.azurerm_subnet.app.id
}
- No access to anyone's state - only permission to read the subnet in Azure, which the app's identity usually needs anyway.
- It depends on names, which a naming convention already keeps stable.
- It works whether the network is managed by Terraform, Bicep (12.1) or by hand.
HashiCorp's own guidance prefers this in most cases: put stable facts in the platform (names, tags) and look them up with data sources.
Option 3: publish values somewhere neutral
The producer writes its values into a shared store - for example Azure App Configuration (a service that holds key/value settings) or a Key Vault - as resources in its own configuration. Consumers read them with data sources. More moving parts, but the two sides no longer depend on each other's code, and it works across tools.
remote_state simplest; needs read on the whole state; tight coupling on output names
data sources no state access; couples on names; works across tools <- default
neutral store most decoupled; more to build
Moving an object between states
terraform state mv (13.15) only works inside one state. With a remote backend there is no move between states. You do it in two reviewed steps, and the order matters.
Step 1 - the source configuration lets go. Delete the resource block and put a removed block in its place. removed says "forget this address"; destroy = false says "and do not delete the real object":
# network/main.tf: the block is deleted and replaced by
removed {
from = azurerm_subnet.batch
lifecycle {
destroy = false
}
}
The plan says so in plain words:
# azurerm_subnet.batch will no longer be managed by Terraform, but will not be destroyed
╷
│ Warning: Some objects will no longer be managed by Terraform
│
│ If you apply this plan, Terraform will discard its tracking information for
│ the following objects, but it will not delete them:
│ - azurerm_subnet.batch
╵
Apply. The subnet still exists; no state mentions it.
Step 2 - the destination configuration adopts it with an import block (13.25) and a matching resource block:
# batch/main.tf
import {
to = azurerm_subnet.batch
id = "/subscriptions/.../virtualNetworks/vnet-spoke/subnets/batch"
}
resource "azurerm_subnet" "batch" {
name = "batch"
resource_group_name = "rg-network-dev"
virtual_network_name = "vnet-spoke"
address_prefixes = ["10.41.9.0/24"]
}
Plan: 1 to import, 0 to add, 0 to change, 0 to destroy.
Apply. Now exactly one state manages it.
Rules for doing this safely:
- Never let both states manage the object, even briefly: they apply conflicting changes, and neither state's lock helps (13.11).
- Never leave the gap open: between step 1 and step 2 nobody manages it. Do both in one change window (an agreed time slot when this change, and nothing else, happens).
- Check that the destination plan says 1 to import, 0 to change before applying. Any change means the new block does not match reality.
- Both blocks can be deleted after their applies: the
removedblock from the source, theimportblock from the destination.
terraform state rm in the source does the same as step 1, but nobody can review it. Before Terraform 1.7 (no removed block) it was the only way.
Moving everything: splitting a state
Splitting a big state - moving the whole network out of an "everything" state - is the same two steps for many objects:
- New configuration
network/with the resource blocks and oneimportblock per object (or afor_eachimport). Plan - expect N to import, 0 to change. Do not apply yet. - In the old configuration, replace those blocks with
removed { ... destroy = false }, and turn references to the network (azurerm_subnet.app.id) into data sources orterraform_remote_statereads. Plan - expect N no longer managed, 0 to destroy, and no changes to anything that used them. - Apply the old configuration, then straight away the new one.
- Plan both. Both clean.
A state split is a change like any other: it goes through PRs and pipelines, and nobody applies anything else to those states while it is in flight.
What you can now do:
- read another configuration's values with
terraform_remote_stateor a data source, and say why data sources are the default - move one object between states with
removed+import, in the right order