Terraform keeps a state file that maps each resource in your configuration to a real object in your provider. Most day-to-day problems with Terraform, such as adopting infrastructure created by hand, renaming resources without destroying them, or moving state to a shared backend, are really state operations. In this tutorial you will work on Ubuntu 24.04 with AWS as the example provider: you will import an existing S3 bucket, refactor it with moved and removed blocks, migrate the state to an S3 backend with locking, and detect drift.
Prerequisites
To follow this tutorial you need:
- A machine running Ubuntu 24.04 LTS, for example a CubePath VPS, with a non-root user that has
sudoprivileges. - Terraform 1.10 or newer (Step 1 installs it). Import blocks need 1.5,
removedblocks 1.7, and S3 native locking 1.10. - An AWS account and an IAM user or role with permission to manage S3 buckets. Everything here fits in the AWS free tier.
- Basic knowledge of Terraform (
init,plan,apply).
The state commands and blocks shown are the same for every provider; only the resource types and import IDs change.
Step 1 - Installing Terraform and the AWS CLI
Install Terraform from HashiCorp's official APT repository:
sudo apt update
sudo apt install gnupg curl
curl -fsSL https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/hashicorp.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/hashicorp.gpg] https://apt.releases.hashicorp.com $(. /etc/os-release && echo "$VERSION_CODENAME") main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
sudo apt update
sudo apt install terraform
Install the AWS CLI, which you will use to create the "existing" resources, and configure your credentials:
sudo snap install aws-cli --classic
aws configure
Enter your access key, secret key and a default region such as eu-west-1. Verify both tools:
terraform version
aws sts get-caller-identity
The second command prints your AWS account ID and user ARN. Terraform's AWS provider reads the same credentials from ~/.aws/.
Step 2 - Creating a resource outside Terraform
To practice importing, create a bucket by hand, the way a lot of real infrastructure starts. Bucket names are global and may only contain lowercase letters, numbers, dots and hyphens, so pick a unique one and use it everywhere your-legacy-bucket appears:
aws s3api create-bucket --bucket your-legacy-bucket --region eu-west-1 \
--create-bucket-configuration LocationConstraint=eu-west-1
{
"Location": "http://your-legacy-bucket.s3.amazonaws.com/"
}
Step 3 - Importing the bucket with an import block
Since Terraform 1.5, imports are declared in configuration with import blocks. Unlike the older terraform import command, they show up in plan, can be reviewed like any other change, and can generate the resource configuration for you.
Create a project:
mkdir -p ~/tf-state-demo && cd ~/tf-state-demo
nano main.tf
terraform {
required_version = ">= 1.10"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
}
provider "aws" {
region = "eu-west-1"
}
Then declare what to import. The id is whatever the provider documents as the import ID for that resource type; for aws_s3_bucket it is the bucket name:
nano imports.tf
import {
to = aws_s3_bucket.legacy
id = "your-legacy-bucket"
}
Initialize and let Terraform write the matching resource block into a new file:
terraform init
terraform plan -generate-config-out=generated.tf
aws_s3_bucket.legacy: Preparing import... [id=your-legacy-bucket]
aws_s3_bucket.legacy: Refreshing state... [id=your-legacy-bucket]
...
Plan: 1 to import, 0 to add, 0 to change, 0 to destroy.
Open generated.tf. It contains every attribute the provider reported, including computed and default values. Trim it down to what you actually want to manage:
nano generated.tf
resource "aws_s3_bucket" "legacy" {
bucket = "your-legacy-bucket"
tags = {
ManagedBy = "terraform"
}
}
Run the plan again. It should import the bucket and only add the tag you just defined:
terraform plan
Plan: 1 to import, 0 to add, 1 to change, 0 to destroy.
A plan that wants to destroy or replace an imported resource means the configuration does not match reality; fix the attributes before applying. When the plan looks right, apply it:
terraform apply
Verify the resource is now in state:
terraform state list
terraform state show aws_s3_bucket.legacy
aws_s3_bucket.legacy
The import block has done its job. You can delete imports.tf now; leaving it in place is harmless because an import of an already managed resource is a no-op.
NoteThe older
terraform import aws_s3_bucket.legacy your-legacy-bucketcommand still works and writes to state immediately, without a plan. Prefer import blocks for anything you would want to review.
Step 4 - Backing up the state before refactoring
Before any state surgery, keep a copy you can restore. terraform state pull prints the current state from whatever backend you use:
terraform state pull > "state-backup-$(date +%F-%H%M).tfstate"
The state contains resource attributes and sometimes secrets, so store backups somewhere private and never commit *.tfstate files to Git. Add them to .gitignore:
printf '%s\n' '*.tfstate' '*.tfstate.*' '.terraform/' >> .gitignore
Step 5 - Renaming a resource with a moved block
If you rename aws_s3_bucket.legacy to aws_s3_bucket.assets in the configuration, Terraform sees one resource removed and a new one added, and plans to destroy the bucket. A moved block tells it the object is the same and only its address changed.
Rename the resource in generated.tf (you can also rename the file to s3.tf):
resource "aws_s3_bucket" "assets" {
bucket = "your-legacy-bucket"
tags = {
ManagedBy = "terraform"
}
}
Add the move to a moved.tf file:
moved {
from = aws_s3_bucket.legacy
to = aws_s3_bucket.assets
}
terraform plan
# aws_s3_bucket.legacy has moved to aws_s3_bucket.assets
resource "aws_s3_bucket" "assets" {
id = "your-legacy-bucket"
# (10 unchanged attributes hidden)
}
Plan: 0 to add, 0 to change, 0 to destroy.
Apply it to record the new address in state:
terraform apply
The same block moves resources into or out of modules, for example to = module.storage.aws_s3_bucket.assets. Keep moved blocks in shared modules for a while so that every consumer's state gets updated; in a root module you can delete them once applied.
The imperative equivalent is terraform state mv aws_s3_bucket.legacy aws_s3_bucket.assets. It changes state immediately with no plan, so it is best kept for one-off fixes.
Step 6 - Removing a resource from Terraform without destroying it
Sometimes a resource should stop being managed by this configuration (for example, it is being handed to another team's project) but must keep existing. Deleting its resource block alone would destroy it. A removed block with destroy = false drops it from state only.
Delete the aws_s3_bucket.assets resource block and the moved block, then create removed.tf:
removed {
from = aws_s3_bucket.assets
lifecycle {
destroy = false
}
}
terraform plan
# aws_s3_bucket.assets will no longer be managed by Terraform, but will not be destroyed
# (destroy = false is set in the configuration)
...
Plan: 0 to add, 0 to change, 0 to destroy.
Apply it, then check that the state is empty while the bucket still exists:
terraform apply
terraform state list
aws s3api head-bucket --bucket your-legacy-bucket && echo "bucket still exists"
The imperative equivalent is terraform state rm aws_s3_bucket.assets.
To continue with the next steps, bring the bucket back under management: delete removed.tf, restore the aws_s3_bucket.assets block, and add an import block for it:
import {
to = aws_s3_bucket.assets
id = "your-legacy-bucket"
}
terraform apply
Step 7 - Migrating state to an S3 backend with locking
Local state works for one person on one machine. For a team or CI you need a remote backend that everyone shares and that locks the state during operations. Since Terraform 1.10 the S3 backend can lock with a lock file in the bucket itself (use_lockfile), so a DynamoDB table is no longer needed.
Create a dedicated state bucket and enable versioning, which gives you a history of every state version:
aws s3api create-bucket --bucket your-state-bucket --region eu-west-1 \
--create-bucket-configuration LocationConstraint=eu-west-1
aws s3api put-bucket-versioning --bucket your-state-bucket \
--versioning-configuration Status=Enabled
Add the backend configuration to the terraform block in main.tf:
terraform {
required_version = ">= 1.10"
backend "s3" {
bucket = "your-state-bucket"
key = "demo/terraform.tfstate"
region = "eu-west-1"
encrypt = true
use_lockfile = true
}
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
}
Re-initialize with -migrate-state. Terraform detects the backend change and offers to copy the existing local state:
terraform init -migrate-state
Do you want to copy existing state to the new backend?
...
Enter a value: yes
Successfully configured the backend "s3"! Terraform will automatically
use this backend unless the backend configuration changes.
Verify that Terraform reads the state from S3:
terraform state list
aws s3 ls s3://your-state-bucket/demo/
aws_s3_bucket.assets
2026-09-25 10:12:03 2961 terraform.tfstate
Once confirmed, remove the local copies so nobody uses them by mistake:
rm -f terraform.tfstate terraform.tfstate.backup
To move from one remote backend to another the process is the same: change the backend block and run terraform init -migrate-state. If you only changed backend settings and do not want to copy anything, use terraform init -reconfigure instead.
Step 8 - Detecting and resolving drift
Drift happens when someone changes a managed resource outside Terraform. Simulate it by changing the bucket tags with the CLI:
aws s3api put-bucket-tagging --bucket your-legacy-bucket \
--tagging 'TagSet=[{Key=ManagedBy,Value=console}]'
A refresh-only plan compares state with reality without proposing changes to the infrastructure:
terraform plan -refresh-only
Note: Objects have changed outside of Terraform
# aws_s3_bucket.assets has changed
~ resource "aws_s3_bucket" "assets" {
~ tags = {
~ "ManagedBy" = "terraform" -> "console"
}
You now have two choices:
- Accept the change into state with
terraform apply -refresh-only, and then update the configuration to match it. - Revert the change with a normal
terraform apply, which sets the tag back toterraformas declared in the code.
For scheduled checks, terraform plan -detailed-exitcode exits with 0 when there are no changes, 1 on error and 2 when there are differences, which is easy to alert on from a CI job or a systemd timer.
terraform refresh still exists but applies the refresh without showing you what changed; plan -refresh-only is the safer replacement.
Troubleshooting
Error acquiring the state lock: another operation is running, or a previous one crashed and left the lock. Make sure nothing else is running (CI jobs included), then release it withterraform force-unlock LOCK_ID, using the ID from the error message.Resource already managed by Terraformwhen importing: the object is already in state under another address. Find it withterraform state listand use amovedblock instead.- Import plan wants to replace the resource: an attribute in the configuration forces replacement (for example a different
bucketname). Compare withterraform state showor the generated config and fix the block. Backend configuration changedonterraform init: Terraform refuses to guess. Use-migrate-stateto copy state or-reconfigureto start fresh against the new settings.- Corrupted or wrong state after manual edits: restore a version with S3 versioning, or push your backup with
terraform state push state-backup-....tfstate. Terraform refuses to push a state with a lower serial or different lineage unless you add-force; use it only when you are sure the backup is the right one.
Cleaning up
Destroy the bucket managed by Terraform and, when you no longer need them, the state bucket and its versions:
terraform destroy
The state bucket cannot be deleted while it contains object versions; empty it from the S3 console (the Empty action removes all versions) and then delete it.
Conclusion
You imported an existing bucket with an import block and generated configuration, renamed it with moved, released it with removed, migrated the state to S3 with native locking and detected drift with a refresh-only plan. Next, import larger sets of resources with for_each in import blocks, split big states into smaller root modules, and run terraform plan -detailed-exitcode on a schedule to catch drift early.
