Why remote state decides whether Terraform is safe
Terraform is only as trustworthy as its state. The state file maps every resource in your code to a real object in your cloud account. If it is lost, Terraform proposes to create things that already exist. If two engineers write to it at once, it can be corrupted. If it sits on a laptop, only one person can safely change production.
Most teams meet this the hard way: a first project grows, a second engineer joins, and suddenly there is a terraform.tfstate file in a shared drive. Setting up Terraform remote state on S3 or Azure Blob is the first thing we do on any engagement, because everything else (modules, pipelines, drift checks) depends on it. This page covers how we do it as part of our wider Terraform and infrastructure as code service.
What a good backend looks like
A production-grade backend has five properties, and we check each one:
- Remote and shared. State lives in your cloud account, not on a machine.
- Versioned. Every write is recoverable, so a corrupted or wrongly edited state can be rolled back.
- Encrypted. Server-side encryption at rest, with a customer-managed key where policy requires it.
- Locked. Only one write operation runs at a time per state.
- Restricted. Read and write access is limited to the CI role and a short list of administrators, because state files can hold secrets in plain text.
How it works on AWS
The backend bucket is created by a small, separate bootstrap configuration, so the main code never has to manage the place its own state lives. A typical backend block looks like this:
terraform {
required_version = ">= 1.10"
backend "s3" {
bucket = "example-tfstate-eu-west-1"
key = "network/prod/terraform.tfstate"
region = "eu-west-1"
encrypt = true
use_lockfile = true
}
}
On older Terraform versions the same job was done with a DynamoDB table (dynamodb_table = "tf-locks") keyed on LockID. Both approaches work. We use native S3 locking for new builds because it removes a moving part, and we keep DynamoDB only where a client is pinned to an older release. Check the options available for the Terraform version you run, as backend behaviour has changed across releases.
The bucket itself gets versioning, default encryption, a public access block, a lifecycle rule to expire old noncurrent versions, and a policy that denies insecure transport.
How it works on Azure
The azurerm backend writes state to a container in a storage account and uses blob leases for locking, so there is no equivalent of the DynamoDB table to maintain. The equivalent configuration is:
terraform {
backend "azurerm" {
resource_group_name = "rg-tfstate"
storage_account_name = "exampletfstate001"
container_name = "tfstate"
key = "network.prod.tfstate"
use_azuread_auth = true
}
}
We prefer Microsoft Entra ID authentication over storage account keys, enable blob versioning and soft delete, and turn off shared key access where the organisation allows it. If you need detail on provider specifics, see our dedicated pages on Terraform for Azure and Terraform for AWS.
Decisions and trade-offs
Self-managed backend or a hosted one
A cloud bucket costs very little and keeps data in your account. A hosted backend such as HCP Terraform adds a UI, run history and policy features, but brings a per-resource or per-seat bill and another vendor. We cover that choice on our page about Atlantis and Terraform Cloud pipelines.
How small should a state be
There is no magic number, but a useful test is: if a plan takes more than a couple of minutes, or if two unrelated teams are blocked by the same lock, split it. Going too far has a cost too. Dozens of tiny states with tangled cross-references are hard to reason about. We aim for stacks that match how your team actually works.
When not to bother with all this
If you have a throwaway proof of concept used by one person, local state is fine. The moment a second person or any production workload is involved, move to a remote backend.
Recovering from a bad state
Versioning is only useful if the recovery path has been rehearsed. The drill we run with clients is simple: list the earlier versions of the state object, restore the last known-good one as the current version, run a plan and confirm it matches expectations. Doing this once in a sandbox, before it is needed, turns a stressful incident into a ten-minute task. We also keep a copy of critical state in a separate account or region where policy calls for it, so a single compromised account cannot remove both the infrastructure and its record.
Failure modes we design out
- Secrets in state. Database passwords and keys can end up in plain text. We restrict bucket access, mark outputs
sensitive, and keep real secrets in a secrets manager. - Stale locks. A cancelled pipeline can leave a lock behind. Our runbook explains how to confirm nobody is applying and then release it with
terraform force-unlock. - Hand-edited state. Direct edits are replaced with
terraform state mv,movedblocks andremovedblocks, each followed by a plan that must show no changes. - A state bucket managed by the state it holds. Bootstrapping separately avoids the circular dependency.
- Shared credentials. Long-lived access keys in CI are replaced with OIDC federation, so a leaked pipeline log gives an attacker nothing reusable.
Delivery and handover
You get the repository, the backend configuration, a state layout diagram and a runbook covering restore, unlock, migration and access changes. Everything lives in your accounts. We train your engineers to run state operations themselves, which fits with our team training and code review work.
How to tell it is working
Look for these signs:
- Every environment’s plan runs from the pipeline, never from a laptop.
- No local
.tfstatefiles exist anywhere. - A previous state version can be restored in minutes, and you have tested it.
- Lock errors are rare and have a documented fix.
- New engineers can run a plan on day one without being sent credentials over chat.
If your state is currently on someone’s machine, or you are unsure who can read it, book a consultation and we will review it with you.