Skip to content

Terraform State Management

Scope: azure-infra, aws-infra, azure-dde-infra Auth model: OIDC federated identity in GitHub Actions; no stored credentials Applies: Never locally. All applies go through GitHub Actions after PR merge.


Hard Rules

  • Never terraform apply locally. State inspection and planning are safe locally. Applying is not.
  • Never edit state files manually. Use terraform state mv, terraform state rm, or terraform import. Direct JSON edits corrupt state and are unrecoverable.
  • Never force-unlock without knowing why the lock exists. A legitimate apply running in CI holds the lock. Force-unlocking it mid-apply corrupts state.
  • Never manipulate state without a backup. Verify versioning is enabled and note the current version ID before any state operation.
  • Rory reviews all diffs before anything is applied.

1. State Storage Locations

azure-infra (Azure PROD — tenant d477c9f8, ciriusgroup.com)

ItemValue
Storage accountciruisinfratfstate (prod subscription)
Containertfstate
State blobazure-infra.tfstate
Locking mechanismAzure Blob Storage lease (automatic, built into azurerm backend)
VersioningAzure Blob soft delete + versioning enabled

Backend config in azure-infra/backend.tf:

hcl
terraform {
  backend "azurerm" {
    resource_group_name  = "rg-terraform-state"
    storage_account_name = "ciruisinfratfstate"
    container_name       = "tfstate"
    key                  = "azure-infra.tfstate"
  }
}

azure-dde-infra (Azure DDE — tenant ff1c5d68, ciriusdde.com)

ItemValue
Storage accountciriusddetfstate (DDE subscription)
Containertfstate
State blobazure-dde-infra.tfstate
Locking mechanismAzure Blob Storage lease
VersioningAzure Blob soft delete + versioning enabled

aws-infra (AWS — one state file per account)

Each AWS account has its own state bucket and DynamoDB lock table, both provisioned in the management account and shared across org accounts via bucket policy.

AccountAccount IDS3 BucketKey
Management206820231356cirius-tf-state-managementmanagement/terraform.tfstate
Backup863609217450cirius-tf-state-backupbackup/terraform.tfstate
Dev040067931468cirius-tf-state-devdev/terraform.tfstate
Identity414134953818cirius-tf-state-identityidentity/terraform.tfstate
Logging038901680748cirius-tf-state-logginglogging/terraform.tfstate
Networking238342914131cirius-tf-state-networkingnetworking/terraform.tfstate
Prod807267566999cirius-tf-state-prodprod/terraform.tfstate

DynamoDB lock table: cirius-tf-lock in us-east-1 (management account, accessed cross-account via IAM role).

Backend config pattern in aws-infra/<account>/backend.tf:

hcl
terraform {
  backend "s3" {
    bucket         = "cirius-tf-state-prod"
    key            = "prod/terraform.tfstate"
    region         = "us-east-1"
    dynamodb_table = "cirius-tf-lock"
    encrypt        = true
  }
}

Accessing State Directly (Read-Only)

You should almost never need to download the raw state file. Use terraform state list and terraform state show instead (see Section 2). If you do need to inspect the raw JSON for debugging:

Azure — download state blob:

bash
# Authenticate
az login --tenant d477c9f8-xxxx-xxxx-xxxx-xxxxxxxxxxxx

# Download to a local file for inspection (do not commit this file)
az storage blob download \
  --account-name ciruisinfratfstate \
  --container-name tfstate \
  --name azure-infra.tfstate \
  --file /tmp/azure-infra-state-$(date +%Y%m%d).tfstate \
  --auth-mode login

# Inspect — pretty-print the JSON
python3 -m json.tool /tmp/azure-infra-state-$(date +%Y%m%d).tfstate | less

# Clean up when done
rm /tmp/azure-infra-state-*.tfstate

AWS — download state from S3:

bash
# Authenticate as a role with read access (kobe-bot ReadOnlyAccess instance profile or
# assume the github-deploy-main role in read-only mode)
aws s3 cp s3://cirius-tf-state-prod/prod/terraform.tfstate /tmp/aws-prod-state-$(date +%Y%m%d).tfstate

# Inspect
python3 -m json.tool /tmp/aws-prod-state-$(date +%Y%m%d).tfstate | less

# Clean up
rm /tmp/aws-prod-state-*.tfstate

The raw state file contains sensitive data (resource IDs, some attribute values). Treat it like a secret — do not share it, do not commit it, delete the local copy when done.


2. Inspecting State

These commands are safe to run locally. They only read state; they do not modify anything. Run them from the repo directory after terraform init.

List all resources in state

bash
terraform state list

Output is one resource address per line, e.g.:

module.windows_server["dc01"].azurerm_windows_virtual_machine.this
azurerm_key_vault.cirius_openai_kv_prod
module.vnet["prod"].azurerm_virtual_network.this

Filter by pattern using grep:

bash
terraform state list | grep key_vault
terraform state list | grep module.windows_server

Inspect a specific resource

bash
terraform state show 'azurerm_key_vault.cirius_openai_kv_prod'
terraform state show 'module.windows_server["dc01"].azurerm_windows_virtual_machine.this'
terraform state show 'aws_instance.logging_vm'

This prints all tracked attributes for the resource — useful for checking what Terraform believes the current state is without running a plan.

Show entire state (full dump)

bash
terraform show

This is verbose. Prefer terraform state list + terraform state show <resource> for targeted inspection.

Run a plan (read-only, shows what would change)

bash
terraform plan

Safe locally. Never apply locally. A plan tells you what the next apply would do — useful for investigating drift or verifying that a state operation landed correctly.


3. Importing a New Resource

Use when a resource already exists in Azure or AWS but is not in Terraform state. The workflow is always: import first, remediate second.

Principle

  1. Write HCL that matches the existing resource exactly as it is today (warts and all).
  2. Import the resource — gets it into state with zero diff.
  3. Merge to main via PR — CI confirms zero diff before applying.
  4. Open a separate PR to remediate non-compliant attributes (missing tags, wrong encryption setting, etc.).

Never write idealized HCL and then import — you will have a non-zero diff on the first apply, which means CI applies your changes automatically when you only intended to import. Get to zero diff first.

Exception for decommission-bound resources (June 2026 target): Flat import with a # DECOMMISSION-2026-06 comment on the resource block. Do not harden. Do not attach modules. Just get it into state so Terraform can track its deletion cleanly.

Import command syntax

bash
terraform import <resource_address> <provider_resource_id>

The <resource_address> is the Terraform address as it will appear in your HCL. The <provider_resource_id> is the cloud resource's unique identifier (Azure resource ID or AWS ARN/ID).

Example: Azure Virtual Machine

bash
# Azure VM resource ID format:
# /subscriptions/<sub-id>/resourceGroups/<rg>/providers/Microsoft.Compute/virtualMachines/<name>

terraform import \
  'azurerm_windows_virtual_machine.dc01' \
  '/subscriptions/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx/resourceGroups/rg-identity-prod/providers/Microsoft.Compute/virtualMachines/cirius-dc01-prod'

Matching HCL (written before import to produce zero diff):

hcl
resource "azurerm_windows_virtual_machine" "dc01" {
  name                = "cirius-dc01-prod"
  resource_group_name = "rg-identity-prod"
  location            = "eastus2"
  size                = "Standard_D2s_v3"

  admin_username = "cirius-admin"
  admin_password = var.dc01_admin_password

  network_interface_ids = [azurerm_network_interface.dc01.id]

  os_disk {
    caching              = "ReadWrite"
    storage_account_type = "Premium_LRS"
  }

  source_image_reference {
    publisher = "MicrosoftWindowsServer"
    offer     = "WindowsServer"
    sku       = "2022-Datacenter"
    version   = "latest"
  }

  tags = {
    Environment = "prod"
    Owner       = "infrastructure"
    Project     = "cirius-group"
    ManagedBy   = "terraform"
    Lifecycle   = "permanent"
  }
}

After import, run terraform plan and confirm the output is No changes. Your infrastructure matches the configuration. If there are diffs, adjust the HCL to match the real resource before opening a PR.

Example: Azure Key Vault

bash
terraform import \
  'azurerm_key_vault.cirius_openai_kv_prod' \
  '/subscriptions/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx/resourceGroups/rg-logging-logs/providers/Microsoft.KeyVault/vaults/cirius-openai-kv-prod'

Example: AWS EC2 Instance

bash
# AWS instance ID format: i-xxxxxxxxxxxxxxxxx

terraform import \
  'aws_instance.logging_vm' \
  'i-0abc1234def567890'

Matching HCL:

hcl
resource "aws_instance" "logging_vm" {
  ami           = "ami-0xxxxxxxxxxxxxxxxx"
  instance_type = "t3.medium"
  subnet_id     = aws_subnet.private_logging.id

  iam_instance_profile = aws_iam_instance_profile.logging_vm.name

  root_block_device {
    volume_type = "gp3"
    volume_size = 50
    encrypted   = true
  }

  tags = {
    Name        = "cirius-logging-vm-prod"
    Environment = "prod"
    Owner       = "infrastructure"
    Project     = "cirius-group"
    ManagedBy   = "terraform"
    Lifecycle   = "permanent"
  }
}

Import is local-only — PR to apply

terraform import writes to your local state copy (or the remote backend directly if you are authenticated). Because all three repos use remote backends, terraform import writes directly to the remote state in Azure Blob or S3. This means:

  • Do not import on a branch where another apply is in flight.
  • After importing, open a PR immediately — CI will run terraform plan and confirm zero diff in the PR comment before anything is applied.
  • The import itself does not create or modify cloud resources. It only updates state.

4. State Move for Refactoring

Use terraform state mv when you rename a resource in HCL or move a resource into (or out of) a module. The cloud resource does not change — only the Terraform address changes.

When to use state mv vs destroy/recreate

SituationUse state mvDestroy/recreate
Renaming a resource block in HCLYesNo
Moving a resource into a moduleYesNo
Moving a resource between modulesYesNo
Changing the resource typeNoYes
Azure VM name change (immutable)NoYes — requires destroy/recreate
AWS instance type change (can stop/start)No — plan handles itNo

Procedure

Step 1 — Take a note of the current state version.

For Azure:

bash
az storage blob show \
  --account-name ciruisinfratfstate \
  --container-name tfstate \
  --name azure-infra.tfstate \
  --query "properties.etag" \
  --auth-mode login

For AWS:

bash
aws s3api head-object \
  --bucket cirius-tf-state-prod \
  --key prod/terraform.tfstate \
  --query "VersionId"

Record the version ID — this is your rollback point.

Step 2 — Move the resource.

bash
# Basic rename
terraform state mv \
  'azurerm_key_vault.old_name' \
  'azurerm_key_vault.new_name'

# Move a flat resource into a module instance
terraform state mv \
  'azurerm_windows_virtual_machine.dc01' \
  'module.windows_server["dc01"].azurerm_windows_virtual_machine.this'

# Move between module instances
terraform state mv \
  'module.vnet["old_key"].azurerm_virtual_network.this' \
  'module.vnet["new_key"].azurerm_virtual_network.this'

Step 3 — Update the HCL to match the new address. The resource block address in .tf files must match the new state address exactly.

Step 4 — Verify zero diff.

bash
terraform plan

Output must be No changes. If there are diffs, the HCL does not match what was in state. Fix the HCL before proceeding.

Step 5 — Open a PR. CI re-runs the plan. Rory confirms zero diff in the PR comment. Merge.

Moving multiple resources at once

For refactoring a large resource group into a module, batch the moves in a script rather than running them one at a time. Each terraform state mv writes to remote state immediately — if you run ten moves and the eighth fails, you have a partially-migrated state. Plan all moves, then execute in one script:

bash
#!/usr/bin/env bash
set -euo pipefail

# Move DC01 flat resources into windows-server module
terraform state mv 'azurerm_windows_virtual_machine.dc01' 'module.windows_server["dc01"].azurerm_windows_virtual_machine.this'
terraform state mv 'azurerm_network_interface.dc01' 'module.windows_server["dc01"].azurerm_network_interface.this'
terraform state mv 'azurerm_managed_disk.dc01_data' 'module.windows_server["dc01"].azurerm_managed_disk.data'

echo "All moves complete. Run terraform plan to verify zero diff."

Run the script, then immediately run terraform plan. If plan shows unexpected changes, use the version ID from Step 1 to restore (see Section 7).


5. Removing a Resource from State Without Destroying It

terraform state rm removes a resource from Terraform state without touching the cloud resource. The cloud resource continues to exist and run — Terraform simply stops tracking it.

When this is appropriate

  • Decommissioning a resource to another team's management — another team will take ownership, so Terraform should stop tracking it here. The cloud resource stays up.
  • Resource is being decommissioned (June 2026 target) — you want to remove it from state before manually deleting it from the portal/console, so Terraform does not try to recreate it on the next apply.
  • Emergency: state is corrupt and the resource needs to be re-imported from scratch — remove, then reimport cleanly.

When this is NOT appropriate

  • You want to delete the cloud resource. Use terraform destroy -target via CI (with explicit approval) or delete via the provider and let the next plan detect the drift.
  • You want to rename or restructure the resource. Use terraform state mv instead.

Command

bash
terraform state rm 'azurerm_windows_virtual_machine.old_server'
terraform state rm 'module.windows_server["decom-vm"].azurerm_windows_virtual_machine.this'

After running terraform state rm:

  1. The resource block should be removed from HCL in the same commit, or the next plan will show it as a resource to create (Terraform will try to create it again).
  2. If the cloud resource is being handed off to another team, document the handoff in the PR description and confirm with Rory before merging.
  3. If the cloud resource is being decommissioned, the actual deletion happens separately — either manually or via a targeted destroy through CI with an approved CM ticket.

6. State Lock Troubleshooting

How locking works

Azure (azurerm backend): When Terraform starts an operation (plan or apply), it acquires a blob lease on the state blob. The lease is released when the operation completes. If the operation is interrupted (CI runner dies, network drop), the lease is not automatically released. Azure blob leases expire after 60 seconds by default unless the process actively renews them — which the azurerm backend does during a running apply.

AWS (s3 + dynamodb backend): When Terraform starts an operation, it writes a lock record to the cirius-tf-lock DynamoDB table with a lock ID, timestamp, and info about the operation. The record is deleted when the operation completes.

Detecting a stuck lock

When you run terraform plan or any state command and see:

Error: Error locking state: Error acquiring the state lock: storage: service returned error: 
StatusCode=409, ErrorCode=LeaseAlreadyPresent, ErrorMessage=There is already a lease present.

Lock Info:
  ID:        xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
  Path:      tfstate/azure-infra.tfstate
  Operation: OperationTypeApply
  Who:       runner@fv-az123-456
  Version:   1.7.5
  Created:   2026-05-25 14:23:01.123456789 +0000 UTC
  Info:

Before force-unlocking, check:

  1. Is there a GitHub Actions run in progress for this repo? Go to the repo → Actions tab. If a workflow is running, the lock is legitimate — wait for it to finish.
  2. Has the workflow been running for an unexpectedly long time (more than 30 minutes for a normal plan/apply)? It may be hung.
  3. Is the workflow already failed or cancelled? Then the lock is stale.

Force-unlock — Azure

Only do this after confirming no CI run is actively holding the lock.

bash
# The lock ID is shown in the error message above
# For Azure, force-unlock breaks the blob lease

terraform force-unlock <lock-id>

# If terraform force-unlock does not work (azurerm backend sometimes requires direct lease break):
az storage blob lease break \
  --account-name ciruisinfratfstate \
  --container-name tfstate \
  --blob-name azure-infra.tfstate \
  --auth-mode login

The az storage blob lease break command immediately releases the lease regardless of who holds it. Use this only when you have confirmed the lock is stale.

Force-unlock — AWS

bash
# First, find the lock record in DynamoDB
aws dynamodb get-item \
  --table-name cirius-tf-lock \
  --key '{"LockID": {"S": "cirius-tf-state-prod/prod/terraform.tfstate"}}' \
  --region us-east-1

# The output shows who holds the lock and when it was created
# If the lock is stale, delete it

terraform force-unlock <lock-id>

# If terraform force-unlock does not work, delete the DynamoDB item directly:
aws dynamodb delete-item \
  --table-name cirius-tf-lock \
  --key '{"LockID": {"S": "cirius-tf-state-prod/prod/terraform.tfstate"}}' \
  --region us-east-1

Force-unlock safety assessment

SituationSafe to force-unlock?
CI run is currently runningNo. Wait for it to finish. Force-unlocking mid-apply corrupts state.
CI run failed/cancelled, lock not releasedYes. The operation did not complete; the lock is orphaned.
Lock is hours or days old with no matching CI runYes. Orphaned lock from a crashed runner.
You do not know why the lock existsNo. Investigate before acting.

7. State File Backup and Restore

How versioning works

Azure: Both state storage accounts have blob versioning enabled. Every write to the state blob creates a new version automatically. Versions are retained for 90 days. You do not need to do anything — versions are created automatically on every plan and apply.

AWS: S3 bucket versioning is enabled on all state buckets. Every terraform apply creates a new version of the state object. Versions are retained indefinitely (subject to lifecycle rules — check each bucket's lifecycle configuration for the actual retention period).

Listing available versions

Azure:

bash
az storage blob list \
  --account-name ciruisinfratfstate \
  --container-name tfstate \
  --include v \
  --query "[?name=='azure-infra.tfstate'].{versionId:versionId, lastModified:properties.lastModified, size:properties.contentLength}" \
  --output table \
  --auth-mode login

AWS:

bash
aws s3api list-object-versions \
  --bucket cirius-tf-state-prod \
  --prefix prod/terraform.tfstate \
  --query "Versions[*].{VersionId:VersionId, LastModified:LastModified, Size:Size}" \
  --output table

Restoring a previous state version

Only do this if the current state is corrupt or an operation went badly wrong. Discuss with Rory before restoring.

Azure — copy a previous version to current:

bash
# Get the version ID of the version you want to restore from the listing above
VERSION_ID="<version-id-to-restore>"

# Download the old version
az storage blob download \
  --account-name ciruisinfratfstate \
  --container-name tfstate \
  --name azure-infra.tfstate \
  --version-id "$VERSION_ID" \
  --file /tmp/state-restore.tfstate \
  --auth-mode login

# Upload it as the new current version
az storage blob upload \
  --account-name ciruisinfratfstate \
  --container-name tfstate \
  --name azure-infra.tfstate \
  --file /tmp/state-restore.tfstate \
  --overwrite \
  --auth-mode login

# Clean up
rm /tmp/state-restore.tfstate

AWS — restore a previous version:

bash
# Get the version ID from the listing above
VERSION_ID="<version-id-to-restore>"

# Copy the old version to become the current version
aws s3api copy-object \
  --bucket cirius-tf-state-prod \
  --copy-source "cirius-tf-state-prod/prod/terraform.tfstate?versionId=$VERSION_ID" \
  --key prod/terraform.tfstate

# Verify the restore
aws s3api head-object \
  --bucket cirius-tf-state-prod \
  --key prod/terraform.tfstate

After restoring, run terraform plan immediately to assess drift between the restored state and actual cloud resources. Expect diffs — the restore is to recover from state corruption, not to roll back cloud resources.


8. Workspace Strategy

Cirius Group does not use Terraform workspaces.

Why not: Workspaces share a single backend configuration and use a naming convention (<workspace>/terraform.tfstate) to separate state. They work well for identical environments with the same provider configuration. Cirius has three structurally different environments (PROD Azure, DDE Azure, AWS multi-account) with different providers, different credentials, and different account structures. Separate repositories with separate backends is the correct pattern for this topology.

Actual pattern:

RepoState isolationBackend
azure-infraSingle state per repoOne blob in ciruisinfratfstate/tfstate/azure-infra.tfstate
azure-dde-infraSingle state per repoOne blob in ciriusddetfstate/tfstate/azure-dde-infra.tfstate
aws-infraOne state per AWS accountOne S3 object per account, all in per-account buckets

Within aws-infra, isolation is by directory + backend config. Each account directory has its own backend.tf pointing to its own S3 bucket and DynamoDB lock. There is no workspace switching — you cd into the account directory and run Terraform there.

aws-infra/
  management/   # backend → cirius-tf-state-management
  backup/       # backend → cirius-tf-state-backup
  dev/          # backend → cirius-tf-state-dev
  identity/     # backend → cirius-tf-state-identity
  logging/      # backend → cirius-tf-state-logging
  networking/   # backend → cirius-tf-state-networking
  prod/         # backend → cirius-tf-state-prod

Do not run terraform workspace new, terraform workspace select, or any workspace command in any of the three infra repos. They operate on single-state backends.


9. Common Gotchas

Azure VM names are immutable

The VM name is baked into the Azure resource ID and cannot be changed in place. If you need to rename a VM:

  1. The old VM must be destroyed and a new one created with the new name.
  2. This is a disruptive operation — the VM is offline during the window.
  3. Never rename a VM via terraform state mv — that only renames the Terraform address, not the Azure resource. The name mismatch will be detected on the next plan and Terraform will try to destroy/recreate.
  4. Plan the rename as: destroy in one PR (apply removes the VM), create in a second PR (apply creates the new VM). Or destroy and create in the same PR if the downtime window is planned.
  5. All dependent resources (NICs, data disks, recovery vault backup items) must be updated or recreated in the same operation.

AWS resources use the Name tag — rename in place

AWS EC2 instances, security groups, VPCs, and most other resources are identified internally by their resource ID, not their Name. The Name tag is just a label. You can change it in HCL and apply — the resource is not recreated:

hcl
# Before
tags = {
  Name = "cirius-logging-vm-old"
}

# After — safe to apply, no replacement
tags = {
  Name = "cirius-logging-vm-prod"
}

The plan will show ~ tags.Name = "cirius-logging-vm-old" -> "cirius-logging-vm-prod" with a tilde (in-place update). No replacement marker (-/+) means no downtime.

Provider version upgrades can require state format updates

When upgrading the azurerm or aws provider to a new major version, the state format may change. Terraform handles this automatically on the first terraform init + terraform apply after the upgrade, but:

  • Always review the provider changelog for state migration notes before upgrading.
  • Run terraform plan after upgrading the provider in versions.tf — unexpected diffs often appear because the new provider version exposes previously-hidden attributes or changes defaults.
  • If a state migration is required, Terraform will prompt during terraform init. This runs automatically in CI on PR open — review the CI output before merging.
  • Do provider version upgrades in a dedicated PR with no other changes. Mixing a provider upgrade with resource changes makes it impossible to attribute diffs to the right cause.

Modules wrap raw resources — state addresses change on module adoption

Before adopting a module, a resource might live at:

azurerm_windows_virtual_machine.dc01

After wrapping it in the windows-server module:

module.windows_server["dc01"].azurerm_windows_virtual_machine.this

Terraform sees these as two different resources — it will plan to destroy the old one and create the new one unless you run terraform state mv first. Always move state before merging module adoption PRs.

Blob lease vs. DynamoDB lock behavior differs on timeout

Azure blob leases expire after 60 seconds if not renewed. The azurerm Terraform backend renews the lease every 15 seconds during an active operation. If the CI runner is killed hard (SIGKILL, OOM), the lease expires on its own within 60 seconds — you may not need to force-unlock at all. Wait 2 minutes before assuming the lock is stuck.

AWS DynamoDB locks do not expire automatically. A killed CI runner leaves the lock record in DynamoDB indefinitely. Always check the DynamoDB table when an AWS state lock appears stuck — it will not self-resolve.

Partial applies leave state inconsistent

If a terraform apply in CI is cancelled mid-run (the workflow is manually cancelled or times out), some resources may have been created or updated while others have not. The state reflects what Terraform knew at the point of cancellation. On the next plan, Terraform will show the remaining changes. This is expected behavior — review the next plan carefully before approving the merge that re-triggers apply.

terraform import writes to remote state immediately

Unlike most local Terraform operations, terraform import writes directly to the remote backend. There is no local staging step. If you import the wrong resource or with the wrong address, you need to terraform state rm it immediately and re-import correctly. Do not let a bad import sit in state while you figure out the fix — remove it, correct the HCL, and re-import cleanly.

OIDC token expiry during long plans

GitHub Actions OIDC tokens are short-lived (typically 10-minute TTL). Very long terraform plan runs in CI (large state, many resources) can cause the OIDC token to expire before the plan completes, resulting in authentication errors mid-plan. If this happens, the fix is to re-run the workflow — do not attempt to recover mid-operation. If it happens repeatedly, investigate whether the plan can be scoped to a subset of resources or whether state is larger than expected.


Quick Reference — Safe vs. CI-Only

CommandSafe locallyCI only
terraform initYes
terraform validateYes
terraform fmtYes
terraform state listYes
terraform state showYes
terraform showYes
terraform planYes
terraform importYes (writes to remote state — coordinate with Rory)
terraform state mvYes (writes to remote state — coordinate with Rory)
terraform state rmYes (writes to remote state — coordinate with Rory)
terraform force-unlockYes (only when lock is confirmed stale)
terraform applyNeverYes — post-merge only
terraform destroyNeverYes — explicit approval required

State-writing local operations (import, state mv, state rm, force-unlock) are technically safe to run locally but write to the shared remote backend immediately. Coordinate with Rory before running any of them. The next CI plan run will show the result.

Internal use only — Cirius Group