Terraform State skill for Codex

Imports, moves and lock fixes with a backup first. Codex handles Terraform state like a production database: back it up, prefer import, moved and removed blocks that show up in a reviewable plan, and fall back to state mv or rm only with a verified copy and your go-ahead. It splits monolithic states, adopts hand-made cloud resources, clears dead locks after checking nobody is applying, and finishes every job on a plan with no changes.

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- terraform-state

Installs to ~/.agents/skills/terraform-state/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $terraform-state, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Manages Terraform state safely - remote backends with locking (S3 use_lockfile, GCS, azurerm, HCP Terraform), backend migration, config-driven import blocks with generated config, moved and removed blocks, refresh-only plans for drift, stuck lock recovery, and state surgery with state mv, rm, pull and push behind a verified backup. Use when bringing existing cloud resources under Terraform, moving resources between modules or state files, splitting a large state, fixing a stuck lock, recovering from a bad apply, or migrating backends.

Use it when

  • Existing infrastructure must come under Terraform management
  • Resources need to move between modules, workspaces or state files
  • A state lock is stuck or an apply failed partway
  • A backend is being created or migrated

Use something else when

How to check the result

After the operation, terraform plan -detailed-exitcode exits 0 and the state resource count matches the backup plus or minus exactly the resources you intended to change.

The mistake it prevents

Running terraform state rm or deleting a resource block without a removed block, so the next plan either recreates the resource or destroys it.

~/.agents/skills/terraform-state/SKILL.md

--- name: terraform-state description: Manages Terraform state safely - remote backends with locking (S3 use_lockfile, GCS, azurerm, HCP Terraform), backend migration, config-driven import… license: MIT ---

State is the only record mapping your code to real infrastructure. Every operation here must end with a plan that shows exactly the intended changes (usually none), with a backup taken before anything touched state. Prefer declarative blocks (import, moved, removed) that go through plan and review over imperative terraform state commands.

Ground rules

  • Back up before any state change: terraform state pull > backup-$(date +%Y%m%d%H%M%S).tfstate. Check the file is non-empty JSON with the expected serial and resource count. Store it outside the repo: state contains secrets in plain text.
  • Never commit .tfstate files or paste state contents into chat or tickets.
  • Every apply, state rm, state mv, state push, force-unlock and backend migration needs the user’s explicit confirmation, after they have seen the plan or the exact command.
  • One operator at a time. Tell the user to pause CI pipelines that apply this configuration during surgery.
  • Read AGENTS.md for wrapper scripts (Terragrunt, Atlantis, Spacelift, Makefiles); if state is managed through one, use it rather than raw commands.

All of this needs backend credentials and network. Codex should prepare commands and blocks, run read-only ones when access exists, and leave mutating steps to confirmation.

Remote backend with locking

terraform {
  backend "s3" {
    bucket       = "acme-tfstate-prod"
    key          = "network/terraform.tfstate"
    region       = "eu-west-1"
    encrypt      = true
    use_lockfile = true      # S3-native lock file, Terraform 1.10+; dynamodb_table is deprecated
  }
}
BackendLockingNotes
S3use_lockfile = true (1.10+), or legacy DynamoDB tableEnable bucket versioning; block public access; restrict IAM to the CI role
GCSBuilt inEnable object versioning
azurermBlob lease, built inUse Entra ID auth instead of storage keys where possible
HCP Terraform / Terraform EnterpriseBuilt in, plus run historycloud {} block instead of backend
localFile lock onlyNever for shared infrastructure

Backend blocks cannot reference variables; pass per-environment values with terraform init -backend-config=env/prod.s3.tfbackend.

Decision table

GoalUseMinimum version
Bring an existing resource under managementimport block, plus -generate-config-out for a draft1.5 (for_each in import: 1.7)
Rename a resource or move it into or out of a module (same state)moved block1.1
Stop managing a resource without destroying itremoved block with lifecycle { destroy = false }1.7
Move resources to a different state fileremoved in the source plus import in the destination1.7
Recreate one broken resourceterraform apply -replace=ADDR0.15.2
See drift without changing anythingterraform plan -refresh-only0.15.4
Accept drift into stateterraform apply -refresh-only (confirm)0.15.4
Swap provider source (fork, registry move)terraform state replace-provider0.13
Anything the blocks cannot expressterraform state mv/rm with backupany

Importing

import {
  to = aws_s3_bucket.logs
  id = "acme-prod-logs"
}

import {
  for_each = { a = "subnet-0a1b", b = "subnet-0c2d" }
  to       = aws_subnet.private[each.key]
  id       = each.value
}
terraform plan -generate-config-out=generated.tf     # drafts HCL for imports that have no resource block

Generated config is a starting point: it hard-codes every attribute, includes computed and default values, and sometimes conflicting arguments. Clean it into the project’s style, move values into variables, and re-plan until the plan shows N to import, 0 to add, 0 to change, 0 to destroy. A change in that plan means the code does not yet match reality; fix the code, not the resource. Delete the import blocks after the apply (or keep them; they are no-ops once imported).

Removing from management

removed {
  from = aws_instance.legacy_bastion
  lifecycle {
    destroy = false
  }
}

Plan shows the resource will be forgotten, not destroyed. Deleting the resource block without a removed block plans a destroy.

Moving between state files

Example: split network out of a monolithic platform state.

  1. Back up both states.
  2. In the destination config, add the resource blocks and import blocks with the real IDs.
  3. In the source config, delete the resource blocks and add removed blocks with destroy = false.
  4. Plan both. Source: only removals. Destination: only imports.
  5. With confirmation, apply the destination first (now managed twice, briefly), then the source.
  6. Plan both again: no changes.

Outputs consumed by other stacks via terraform_remote_state must move too; update the consumers before removing outputs from the source.

For the imperative path (state mv with -state-out, state rm, state push), stuck locks, corrupted or lost state, and backend migration step by step, read references/state-surgery.md before running any of those commands.

Drift

terraform plan -refresh-only           # what changed outside Terraform
terraform plan -detailed-exitcode      # 0 = no changes, 2 = changes, 1 = error (CI drift check)

For each drifted attribute decide: revert reality (normal plan/apply), accept it into code (edit HCL to match), or ignore it (lifecycle { ignore_changes = [tags["LastScanned"]] } for fields another system owns). Do not blanket ignore_changes = all. Ongoing drift detection and reconciliation policy belongs to $infrastructure-drift.

Workflow

- [ ] 1. Identify backend, workspace and state address(es) involved
- [ ] 2. terraform plan: start from a clean, no-change plan if at all possible
- [ ] 3. Back up state (state pull) and pause applying pipelines
- [ ] 4. Write import / moved / removed blocks (or prepare state commands)
- [ ] 5. Plan and show the user the exact result; get confirmation
- [ ] 6. Apply (or run the command)
- [ ] 7. Plan again: must show no changes; keep the backup until confirmed

If step 2 already shows unrelated changes, stop and resolve them first; surgery on top of pending drift makes the final plan impossible to interpret.

Verification

terraform state list | wc -l                       # compare with the backup count +/- intended
terraform state show 'module.network.aws_vpc.this'
terraform plan -detailed-exitcode; echo "exit=$?"  # expect 0
jq '.serial, (.resources | length)' backup-*.tfstate

Output

**Operation:** split network resources from platform state into network state
**Backups:** platform serial 412, network serial 3 (stored in s3://acme-tfstate-backups/2026-10-28/)
**Method:** 14 import blocks (destination), 14 removed blocks with destroy = false (source)
**Plans before apply:** destination 14 to import, 0 change; source 14 to forget, 0 destroy
**After:** both plans exit 0; 2 terraform_remote_state consumers updated to the new key
**Pending:** user to unpause the Atlantis workflow

What the zip contains

Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "Terraform State"
  short_description: "Imports, moves and lock fixes with a backup first"
  default_prompt: "$terraform-state Import these existing AWS resources into Terraform without recreating them"
references/state-surgery.md123 lines, 5.0 KB
# Terraform state surgery

Imperative commands for cases the `import`, `moved` and `removed` blocks cannot cover.
Every command below that writes state needs a fresh backup and the user's explicit
confirmation of the exact command first.

## Backup and inspect

```bash
terraform state pull > backup-$(date +%Y%m%d%H%M%S).tfstate
jq '{version, terraform_version, serial, lineage, resources: (.resources | length)}' backup-*.tfstate
terraform state list
terraform state show 'aws_instance.web["primary"]'
```

`serial` increments on every write; `lineage` identifies the state's history. Terraform
refuses to push a state with a different lineage or a lower serial unless forced.

Quote addresses containing brackets in shells: `'module.app.aws_instance.web["a"]'`.

## Rename or move within one state

```bash
terraform state mv 'aws_instance.web' 'module.compute.aws_instance.web'
terraform state mv 'aws_instance.web[0]' 'aws_instance.web["primary"]'
```

Prefer a `moved` block: it is reviewed in plan and works for every caller of a module.
Use `state mv` only for one-off fixes in a root module you own outright.

## Move between two state files

The `removed` plus `import` approach in SKILL.md is safer. If you must move state directly:

```bash
terraform -chdir=platform state pull > platform.tfstate
terraform -chdir=network  state pull > network.tfstate
cp platform.tfstate platform.backup.tfstate; cp network.tfstate network.backup.tfstate

# -state/-state-out are legacy local-file flags: run this from an empty scratch
# directory with no backend block, so they operate on the pulled files only
mkdir -p surgery && cp platform.tfstate network.tfstate surgery/ && cd surgery
terraform state mv -state=platform.tfstate -state-out=network.tfstate \
  'aws_vpc.main' 'aws_vpc.main'
cd .. && cp surgery/platform.tfstate surgery/network.tfstate .

terraform -chdir=network  state push ../network.tfstate
terraform -chdir=platform state push ../platform.tfstate
```

Then update both configurations so the resource block exists only in `network`, and plan
both: each must show no changes. Push the destination first so a failure in between
leaves the resource tracked twice (recoverable) rather than nowhere.

## Forget a resource

```bash
terraform state rm 'aws_instance.legacy'
```

The resource keeps running and is no longer managed. If the resource block is still in
the code, the next plan creates a duplicate. Prefer a `removed` block.

## Stuck lock

Symptom: `Error acquiring the state lock` with a lock ID, who, and when.

1. Find out whether the holder is still running: CI job, a teammate's terminal. A lock
   from a running apply must not be broken.
2. If the holder is gone (killed job, crashed laptop), confirm with the user, then:

```bash
terraform force-unlock <LOCK_ID>
```

3. Run `terraform plan` immediately. An apply that died midway may have created resources
   that are not in state; import them rather than letting the next apply create duplicates.

With S3 `use_lockfile`, the lock is a `<key>.tflock` object next to the state. With the
legacy DynamoDB table, the lock is an item keyed by `<bucket>/<key>`. Deleting those by
hand is equivalent to force-unlock and needs the same confirmation.

## Bad apply or corrupted state

- Versioned bucket (S3, GCS): list object versions of the state key, download the last
  good version, inspect it with jq, then push it (with confirmation). Pushing an older
  serial requires `terraform state push -force`; understand that resources created after
  that version will be missing from state and must be imported.
- HCP Terraform: state versions are listed per workspace; roll back from there.
- No backup and no versioning: rebuild state by importing every resource with `import`
  blocks. Slow, but safe and reviewable.

## Backend migration

```bash
terraform state pull > pre-migration.tfstate
# edit the backend block (e.g. local -> s3, or change bucket/key)
terraform init -migrate-state
terraform state list | wc -l          # same count as before
terraform plan -detailed-exitcode     # expect exit 0
```

- `-migrate-state` copies state; it does not delete the old copy. Remove the old object
  only after the user confirms the new backend works and pipelines point to it.
- `-reconfigure` ignores existing state and starts fresh against the new backend config:
  never use it to "fix" a migration error, it can orphan state.
- Workspaces migrate together; check `terraform workspace list` before and after.

## Provider source changes

```bash
terraform state replace-provider registry.terraform.io/-/aws registry.terraform.io/hashicorp/aws
```

Needed after legacy 0.12 upgrades or when switching to a fork or another registry
(for example OpenTofu's registry; see the opentofu skill).

## Sensitive data

State stores resource attributes in plain text, including generated passwords and
private keys. Restrict read access to the backend as tightly as the secrets themselves,
enable encryption at rest, and delete local `.tfstate` copies made during surgery once
the work is verified (confirm before deleting backups).

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc
Just launched

Codex Skills Pro is live

79 premium skills for Codex, each with scripts, references and an evidence report that proves the result.

Bought one by one$882.21
All 79 skills$49
94% off

That's $0.62 a skill, paid once.

Get all 79 for $49Or pick single skills from $4.99 →

Secure checkout by Creem