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.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- terraform-state Installs to ~/.agents/skills/terraform-state/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install terraform-state The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project terraform-state Installs to .agents/skills/terraform-state/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/terraform-state.zipunzip -o terraform-state.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) terraform-state PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
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
- You are designing a module's variables, outputs and tests: use terraform-modules
- You want continuous drift detection across many stacks: use infrastructure-drift
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.
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 expectedserialand resource count. Store it outside the repo: state contains secrets in plain text. - Never commit
.tfstatefiles or paste state contents into chat or tickets. - Every
apply,state rm,state mv,state push,force-unlockand 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.mdfor 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
}
}
| Backend | Locking | Notes |
|---|---|---|
| S3 | use_lockfile = true (1.10+), or legacy DynamoDB table | Enable bucket versioning; block public access; restrict IAM to the CI role |
| GCS | Built in | Enable object versioning |
| azurerm | Blob lease, built in | Use Entra ID auth instead of storage keys where possible |
| HCP Terraform / Terraform Enterprise | Built in, plus run history | cloud {} block instead of backend |
| local | File lock only | Never for shared infrastructure |
Backend blocks cannot reference variables; pass per-environment values with
terraform init -backend-config=env/prod.s3.tfbackend.
Decision table
| Goal | Use | Minimum version |
|---|---|---|
| Bring an existing resource under management | import block, plus -generate-config-out for a draft | 1.5 (for_each in import: 1.7) |
| Rename a resource or move it into or out of a module (same state) | moved block | 1.1 |
| Stop managing a resource without destroying it | removed block with lifecycle { destroy = false } | 1.7 |
| Move resources to a different state file | removed in the source plus import in the destination | 1.7 |
| Recreate one broken resource | terraform apply -replace=ADDR | 0.15.2 |
| See drift without changing anything | terraform plan -refresh-only | 0.15.4 |
| Accept drift into state | terraform apply -refresh-only (confirm) | 0.15.4 |
| Swap provider source (fork, registry move) | terraform state replace-provider | 0.13 |
| Anything the blocks cannot express | terraform state mv/rm with backup | any |
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.
- Back up both states.
- In the destination config, add the resource blocks and
importblocks with the real IDs. - In the source config, delete the resource blocks and add
removedblocks withdestroy = false. - Plan both. Source: only removals. Destination: only imports.
- With confirmation, apply the destination first (now managed twice, briefly), then the source.
- 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.yaml
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.md
# 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.