Local #

Local state is Terraform’s default mode — state is stored in a terraform.tfstate file in the same directory as your configuration. No additional configuration is needed. For a developer working alone on a small project or still learning, this is entirely sufficient. But as soon as a second team member joins or the configuration touches production resources, local state starts showing its limitations.

flowchart TD
    A["terraform init"] --> B["terraform plan"]
    B --> C["terraform apply"]
    C --> D["terraform.tfstate\ncreated/updated"]
    D --> B

    style A fill:#e3f2fd,stroke:#1565c0
    style C fill:#e8f5e9,stroke:#2e7d32
    style D fill:#fff3e0,stroke:#e65100

The Structure of the terraform.tfstate File #

The state file is JSON storing the complete mapping between resources in the Terraform configuration and resources that exist in the cloud.

{
  "version": 4,
  "terraform_version": "1.6.3",
  "serial": 12,
  "lineage": "a3b4c5d6-e7f8-9012-abcd-ef1234567890",
  "outputs": {
    "instance_public_ip": {
      "value": "54.123.45.67",
      "type": "string"
    }
  },
  "resources": [
    {
      "mode": "managed",
      "type": "aws_instance",
      "name": "web",
      "provider": "provider[\"registry.terraform.io/hashicorp/aws\"]",
      "instances": [
        {
          "schema_version": 1,
          "attributes": {
            "ami": "ami-0abcdef1234567890",
            "id": "i-0abcdef1234567890",
            "instance_type": "t3.micro",
            "private_ip": "10.0.1.42",
            "public_ip": "54.123.45.67",
            "subnet_id": "subnet-0abcdef1234",
            "tags": {
              "Name": "web-server",
              "Environment": "production"
            }
          }
        }
      ]
    }
  ]
}

A few important fields to understand:

  • serial — a sequence number that increments every time state is modified. Terraform uses this to detect conflicts.
  • lineage — a unique UUID created when the state is first created. Used to verify a state file comes from the same workspace.
  • mode: "managed" — a resource managed by Terraform. It can also be "data" for data sources.
flowchart TD
    A["terraform.tfstate\n(JSON)"] --> B["serial: 12"]
    A --> C["lineage: UUID"]
    A --> D["outputs"]
    A --> E["resources"]
    D --> D1["instance_public_ip\n= 54.123.45.67"]
    E --> E1["aws_instance.web\nami, id, ip, tags"]
    E --> E2["aws_subnet.public\nid, cidr, az"]

    style A fill:#e3f2fd,stroke:#1565c0
    style E fill:#fff3e0,stroke:#e65100
    style D fill:#e8f5e9,stroke:#2e7d32

The State Files That Appear #

After the first terraform apply:

project-directory/
  ├── main.tf
  ├── variables.tf
  ├── outputs.tf
  ├── terraform.tfstate          ← the active state
  ├── terraform.tfstate.backup   ← automatic backup of the previous state
  └── .terraform/
      └── ...

terraform.tfstate.backup is created automatically every time state is updated.
It provides one level of undo if something goes wrong.

Why Local State Isn’t Enough for Teams #

Local state stores everything in a single file on your machine. This creates structural problems when working in a team.

flowchart TD
    A["terraform.tfstate\n(single local file)"] --> B["Developer A\nlocal machine"]
    A --> C["Developer B\nanother machine"]
    C --> D["Empty state\n(can't access)"]
    D --> E["Plan: create everything\nfrom scratch! ❌"]
    A --> F["A & B apply\nsimultaneously"]
    F --> G["State conflict\nor corruption ❌"]

    style A fill:#ffebee,stroke:#c62828
    style E fill:#ffebee,stroke:#c62828
    style G fill:#ffebee,stroke:#c62828
LOCAL STATE PROBLEMS IN TEAMS:

1. CAN'T BE SHARED
   State only exists on developer A's machine.
   Developer B runs terraform plan → empty state
   → The plan shows "will create all resources from scratch"
   → Catastrophic if followed by apply

2. NO LOCKING
   Developer A is applying while developer B also applies.
   Both read the same state (an old version), then both
   write new state — one will overwrite the other.
   → Corrupted state or duplicate resources

3. CONTAINS SENSITIVE INFORMATION
   State can contain passwords, private keys, and API tokens
   in plaintext — depending on the resources being managed.
   If state is committed to Git, every secret leaks to everyone
   with repository access.

4. NO VERSIONING
   terraform.tfstate.backup only stores one previous version.
   If an error happened two steps back, there's no recovery.

Sensitive State — Don’t Commit It to Git #

# Examples of state contents containing sensitive data:

# aws_db_instance stores passwords in state:
# "password": "super-secret-db-password-123"

# aws_iam_access_key stores the secret key:
# "secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"

# tls_private_key stores the private key:
# "private_key_pem": "-----BEGIN RSA PRIVATE KEY-----\n..."
# Make sure .gitignore already excludes state files:
cat .gitignore

# Local .terraform directories
**/.terraform/*

# State files
*.tfstate
*.tfstate.*

When Local State Is Still Appropriate #

Despite its limitations, local state is still the right choice in several situations.

LOCAL STATE IS STILL RIGHT FOR:

  ✓ Learning and experimenting — personal projects without collaborators
  ✓ Disposable projects — environments frequently created and destroyed
  ✓ Stateless CI/CD pipelines — each run starts from fresh state
    (for example: pipelines that only create ephemeral environments)
  ✓ Terraform module development — testing modules locally

YOU ALREADY NEED REMOTE STATE IF:
  ✗ More than one person works on the same infrastructure
  ✗ Resources touch production or important data
  ✗ CI/CD pipelines need access to the same state
  ✗ You're worried about recovery if state is lost

State Commands for Local State #

Even when using local state, all state management operations can be done through the Terraform CLI — not by editing the JSON file manually.

# List all resources
terraform state list

# View details of a specific resource
terraform state show aws_instance.web

# Remove a resource from state (without destroying the resource in the cloud)
terraform state rm aws_instance.web

# Move/rename a resource in state
terraform state mv aws_instance.web aws_instance.web_server

# Pull state to stdout (useful for backup or inspection)
terraform state pull > backup-$(date +%Y%m%d).tfstate

# Push state from a file (careful — can overwrite the active state)
terraform state push backup-20240101.tfstate

Understanding Serial and Lineage #

Two fields in the state file — serial and lineage — are security mechanisms often overlooked, yet both are very important for preventing conflicts and verifying state integrity.

# Serial: a sequence number that increments every time state is modified
# You can see the serial from the state file:
cat terraform.tfstate | jq '.serial'
# Output: 12

# Every successful terraform apply increments the serial:
# first apply → serial: 1
# second apply   → serial: 2
# etc.

# If two people apply simultaneously (without locking), both
# read serial 12. The first to write → serial 13.
# The second also writes → serial 13 (overwriting the first).
# The data from the first apply is lost.
# Lineage: a UUID created once when the state is first initialized
cat terraform.tfstate | jq '.lineage'
# Output: "a3b4c5d6-e7f8-9012-abcd-ef1234567890"

# Lineage is used during terraform state push to verify
# that the pushed file comes from the same workspace:
terraform state push wrong-state.tfstate
# Error: lineage mismatch — the state comes from a different workspace

# This prevents incidents: a developer accidentally pushing state
# from the staging environment to the production environment

Analyzing the State File Manually #

Sometimes you need to look inside the state file for debugging — for example, a resource changed in the cloud but terraform plan doesn’t detect it.

# Find a specific resource in the state file
cat terraform.tfstate | jq '.resources[] | select(.name=="web")'
# Shows all stored data of aws_instance.web

# Check a specific attribute of a resource
cat terraform.tfstate | jq '.resources[]
  | select(.type=="aws_instance" and .name=="web")
  | .instances[0].attributes.public_ip'
# Output: "54.123.45.67"

# Count the number of managed resources
cat terraform.tfstate | jq '[.resources[].instances[]] | length'
# Output: 7 (meaning 7 resource instances in state)

# List all resource types in state
cat terraform.tfstate | jq '[.resources[].type] | unique'
# Output: ["aws_instance", "aws_security_group", "aws_subnet", ...]

# Check for resources containing sensitive data
cat terraform.tfstate | jq '.. | .password? // .secret? // .private_key_pem?
  | select(. != null)' 2>/dev/null
# Output: a list of sensitive values stored in plaintext
# THIS is why state must never be committed to Git

Migrating from Local to Remote State #

When your project grows from a personal experiment into a team project, you need to move the local state to a remote backend. This process must be done carefully because a mistake can cause Terraform to “forget” existing resources.

# STEP 1: Back up the state before migrating
cp terraform.tfstate terraform.tfstate.backup.before-migration

# STEP 2: Create the remote backend (e.g. S3)
# Add the backend configuration to main.tf or backend.tf:
terraform {
  backend "s3" {
    bucket         = "my-terraform-state-bucket"
    key            = "project/terraform.tfstate"
    region         = "ap-southeast-1"
    encrypt        = true
    dynamodb_table = "terraform-state-lock"
  }
}
# STEP 3: Run terraform init to migrate
terraform init
# Terraform detects that the backend changed from local to S3
# and offers to move the state:
#
# "Do you want to copy existing state to the new backend?
#   Pre-existing state was found while migrating the previous
#   "local" backend to the newly configured "s3" backend."
#
# Choose "yes" — Terraform will:
# 1. Read the local state
# 2. Upload it to S3
# 3. Delete the local state (after success)

# STEP 4: Verify the state was successfully moved
terraform state list
# All resources must appear — if empty, something's wrong

# STEP 5: Verify the plan doesn't show changes
terraform plan
# "No changes" — the state is in sync with the remote

Common Pitfalls with Local State #

# PITFALL 1: Deleting the .terraform/ directory
rm -rf .terraform/
terraform plan
# Error: Backend not initialized
# Solution: terraform init (will re-download providers and re-init the backend)

# PITFALL 2: Moving the state file to another computer without .terraform/
# → The other developer doesn't know which backend is used
# → Always include instructions: "Run terraform init first"

# PITFALL 3: Two terminals running terraform apply simultaneously
# Terminal 1: terraform apply → reads state serial 10
# Terminal 2: terraform apply → reads state serial 10 (same!)
# Terminal 1: done → writes state serial 11
# Terminal 2: done → writes state serial 11 (overwriting terminal 1!)
# → Terminal 1's state is lost
# → Local state has no locking to prevent this

# PITFALL 4: State file corrupted due to a full disk or a killed process
# If terraform apply is killed mid-way through writing state:
cat terraform.tfstate
# Output: incomplete JSON — Terraform errors when reading
# Solution: restore from terraform.tfstate.backup

# PITFALL 5: Confusing workspace switching
terraform workspace new staging
# Staging state is separate, but stored in .terraform.tfstate.d/
# Developers often forget which workspace they're in
# Solution: show the workspace in the shell prompt (see the workspace article)

Summary #

  • Local state is the default — no configuration needed, good for learning and personal experiments.
  • terraform.tfstate is a JSON file storing the complete mapping between the configuration and actual cloud resources, including all provider-generated attributes.
  • serial and lineage are used by Terraform to detect conflicts and verify state provenance.
  • State can contain secrets (passwords, API keys, private keys) — never commit it to Git.
  • Three main problems of local state in teams: it can’t be shared, there’s no locking, and there’s no adequate versioning.
  • Move to remote state as soon as there’s team collaboration, production resources, or CI/CD needs accessing the same state.

← Previous: Locking   Next: Remote →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact