Terraform records everything it manages in a state file, and by default that file is terraform.tfstate in your project directory. That works for one person on one machine, but a team needs the state in a shared place, with locking so that two runs never write it at the same time. In this tutorial you will create a small Terraform project on Ubuntu 24.04, migrate its state to a PostgreSQL backend with locking, use workspaces, back up and refactor state safely, and see how to configure the Amazon S3 backend instead.

Prerequisites

To follow this guide you need:

  • A server running Ubuntu 24.04 LTS, for example a CubePath VPS, with at least 1 GB of RAM.
  • A non-root user with sudo privileges.
  • Terraform 1.10 or newer installed from HashiCorp's APT repository. Check with terraform -version.
  • Basic familiarity with terraform init, plan and apply.

If Terraform is not installed yet, add the repository and install it:

sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/hashicorp-archive-keyring.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/hashicorp-archive-keyring.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

What Terraform state contains

State maps each resource in your configuration (for example random_pet.server) to the real object it created, and stores that object's attributes. Terraform compares the configuration, the state and reality on every plan to decide what to change. Three properties of state drive every decision in this guide:

  • It must be shared. If each person keeps a local copy, Terraform on one laptop does not know what another laptop created, and it will try to create everything again.
  • It must be locked. Two apply runs writing the same state at the same time can corrupt it or leave orphaned resources.
  • It contains secrets in plain text. Passwords, private keys and tokens that resources generate or receive are stored in state even when outputs are marked sensitive. Treat state storage like a secrets store.

A remote backend solves the first two and lets you control access for the third.

Step 1 - Creating a project with local state

Start with a small configuration that needs no cloud account. The random provider generates values and stores them in state, which is enough to see how state behaves.

Create the project directory and the configuration file:

mkdir -p ~/tf-state-demo
cd ~/tf-state-demo
nano main.tf
terraform {
  required_version = ">= 1.10"

  required_providers {
    random = {
      source  = "hashicorp/random"
      version = "~> 3.6"
    }
  }
}

resource "random_pet" "server" {
  length = 2
  prefix = terraform.workspace
}

output "server_name" {
  value = random_pet.server.id
}

terraform.workspace is the name of the current workspace, default for now. You will use it in Step 5.

Initialize and apply:

terraform init
terraform apply

Type yes when prompted:

Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

Outputs:

server_name = "default-quick-lemur"

The generated name will differ. List what Terraform now tracks, and confirm that the state is a local file:

terraform state list
ls
random_pet.server
main.tf  terraform.tfstate

Run terraform apply again and it reports no changes, because the name is stored in state. If you deleted terraform.tfstate, Terraform would generate a new name: that is exactly what happens to a teammate who does not have your state file.

Step 2 - Preparing PostgreSQL as a state backend

Terraform's built-in pg backend stores state in a PostgreSQL table and uses PostgreSQL advisory locks for locking. It is a good fit when you already run PostgreSQL or want to keep state on your own servers instead of a public cloud.

Install PostgreSQL from the Ubuntu repositories:

sudo apt update
sudo apt install postgresql

Create a dedicated role and a database owned by it. Replace your_strong_password with a long random password:

sudo -u postgres psql -c "CREATE ROLE terraform LOGIN PASSWORD 'your_strong_password';"
sudo -u postgres psql -c "CREATE DATABASE terraform_state OWNER terraform;"
CREATE ROLE
CREATE DATABASE

Making the terraform role the database owner lets the backend create its own schema, terraform_remote_state, on first use.

The backend reads the connection string from the PG_CONN_STR environment variable, which keeps the password out of your .tf files and out of Git. Store it in a file that only your user can read:

install -m 600 /dev/null ~/.tfstate.env
nano ~/.tfstate.env
export PG_CONN_STR="postgres://terraform:your_strong_password@localhost/terraform_state?sslmode=require"

Load it into your shell and test the connection:

source ~/.tfstate.env
psql "$PG_CONN_STR" -c "SELECT current_user;"
 current_user
--------------
 terraform
(1 row)

Ubuntu's PostgreSQL package enables TLS with a self-signed certificate, so sslmode=require works out of the box. By default PostgreSQL only listens on localhost.

Step 3 - Migrating the state to the pg backend

Add a backend block to the terraform block. Leave it empty: this is a partial configuration, and the connection string comes from PG_CONN_STR.

nano main.tf
terraform {
  required_version = ">= 1.10"

  backend "pg" {}

  required_providers {
    random = {
      source  = "hashicorp/random"
      version = "~> 3.6"
    }
  }
}

Keep the rest of the file unchanged. Changing the backend requires a new init. The -migrate-state flag tells Terraform to copy the existing local state to the new backend:

terraform init -migrate-state

Terraform asks for confirmation:

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 "pg" backend. No existing state was found in the newly
  configured "pg" backend. Do you want to copy this state to the new "pg"
  backend? Enter "yes" to copy and "no" to start with an empty state.

  Enter a value: yes

Successfully configured the backend "pg"! Terraform will automatically
use this backend unless the backend configuration changes.

Verify that the state now lives in PostgreSQL:

psql "$PG_CONN_STR" -c "SELECT id, name FROM terraform_remote_state.states;"
 id |  name
----+---------
  1 | default
(1 row)

Then confirm that Terraform reads it from there and sees no changes:

terraform plan
No changes. Your infrastructure matches the configuration.

The old local files are no longer used. Once plan shows no changes, remove them so nobody edits the wrong copy:

rm -f terraform.tfstate terraform.tfstate.backup

Every teammate now runs terraform init with their own PG_CONN_STR and works against the same state.

Step 4 - Seeing state locking in action

Every operation that can write state (plan, apply, destroy, state commands) takes a lock first. With the pg backend, the lock is a PostgreSQL advisory lock held for the duration of the connection.

Open a second SSH session to the server. In the first session, start an apply and leave it waiting at the confirmation prompt:

cd ~/tf-state-demo
source ~/.tfstate.env
terraform apply -replace=random_pet.server

While the first session waits for yes, run a plan in the second session:

cd ~/tf-state-demo
source ~/.tfstate.env
terraform plan

The second run refuses to proceed:

Error: Error acquiring the state lock

Answer no in the first session. The lock is released, and running terraform plan again in the second session succeeds. In CI pipelines, add -lock-timeout=5m so that a job waits for the lock instead of failing immediately.

Because advisory locks belong to a database connection, a crashed or killed Terraform process releases its lock automatically. Backends that write a lock object instead, such as S3, can leave a stale lock behind. In that case, and only when you are sure no other run is active, remove it with the lock ID printed in the error:

terraform force-unlock LOCK_ID

Step 5 - Using workspaces for separate states

Workspaces give one configuration several independent states in the same backend. In the pg backend, each workspace is a separate row in the states table.

Create a staging workspace and apply:

terraform workspace new staging
terraform apply
Outputs:

server_name = "staging-fond-gecko"

The prefix = terraform.workspace argument in main.tf makes the difference visible. List the workspaces and check the database:

terraform workspace list
psql "$PG_CONN_STR" -c "SELECT name FROM terraform_remote_state.states ORDER BY id;"
  default
* staging

  name
---------
 default
 staging
(2 rows)

Switch back with terraform workspace select default.

Workspaces work well for short-lived copies of the same setup, such as a test environment per feature branch. For long-lived environments like staging and production, which usually need different access rights, variables and review rules, many teams prefer a separate directory (or root module) per environment, each with its own backend configuration. That way a mistyped workspace name can never apply staging changes to production.

Step 6 - Changing state safely

Never edit state by hand. Use Terraform's commands and configuration blocks, which validate what they do and keep the state serial and lineage consistent.

Inspect a single resource:

terraform state show random_pet.server
# random_pet.server:
resource "random_pet" "server" {
    id        = "staging-fond-gecko"
    length    = 2
    prefix    = "staging"
    separator = "-"
}

Renaming a resource with a moved block

Suppose you rename random_pet.server to random_pet.app in main.tf. Without more information, Terraform would destroy the old object and create a new one. Add a moved block next to the renamed resource instead:

resource "random_pet" "app" {
  length = 2
  prefix = terraform.workspace
}

moved {
  from = random_pet.server
  to   = random_pet.app
}

Update the output to reference random_pet.app.id, then run terraform plan:

  # random_pet.server has moved to random_pet.app
    resource "random_pet" "app" {
        id        = "staging-fond-gecko"
        # (3 unchanged attributes hidden)
    }

Plan: 0 to add, 0 to change, 0 to destroy.

Apply it. Because the move is part of the configuration, it is reviewed in the plan and applied to every workspace and every teammate's run, unlike the older terraform state mv command, which changes one state immediately without a plan.

Stopping management without destroying

To remove a resource from Terraform without destroying the real object, delete its resource block (and the server_name output that references it) and add a removed block (Terraform 1.7 and later):

removed {
  from = random_pet.app

  lifecycle {
    destroy = false
  }
}

terraform apply then drops it from state and leaves the object untouched. This replaces terraform state rm for the same reasons as moved replaces state mv.

Step 7 - Backing up and restoring state

A remote backend is only as safe as its backups. With the pg backend, back up the database with the rest of your PostgreSQL data. A compressed dump of just this database looks like this:

sudo -u postgres pg_dump -Fc terraform_state > ~/terraform_state-$(date +%F).dump

Before risky operations, such as a large refactor or a provider major upgrade, also take a snapshot of the current workspace's state with Terraform itself:

terraform state pull > ~/state-backup-$(terraform workspace show)-$(date +%F-%H%M).tfstate

To restore that snapshot, select the same workspace and push it back:

terraform state push ~/state-backup-staging-2026-09-25-1030.tfstate

state push refuses a file with a different lineage or an older serial than the current state, which protects you from overwriting newer state by mistake. It accepts -force to skip those checks; use it only when you have confirmed that the backup is the state you want.

Using the S3 backend instead

If your team works on AWS, the s3 backend is the usual choice. Since Terraform 1.10 it can lock with a lock file in the same bucket (use_lockfile), so the DynamoDB table that older guides require is no longer needed. The dynamodb_table argument is deprecated.

Create a private bucket with versioning enabled, so that every state write keeps the previous version. With the AWS CLI configured, run:

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

New buckets block public access and encrypt objects at rest by default. Then replace the pg backend block with:

terraform {
  backend "s3" {
    bucket       = "your_state_bucket"
    key          = "tf-state-demo/terraform.tfstate"
    region       = "eu-west-1"
    encrypt      = true
    use_lockfile = true
  }
}

Run terraform init -migrate-state to copy the state across, exactly as in Step 3. Credentials come from the usual AWS sources (environment variables, ~/.aws/credentials or an instance role); never put access keys in the backend block, because backend settings are saved in .terraform/ in plain text. Non-default workspaces are stored under the env:/ prefix, for example env:/staging/tf-state-demo/terraform.tfstate.

Securing state

  • Limit who can read it. Anyone who can read state can read every secret in it. Give the backend role access only to its own database or bucket prefix.
  • Encrypt in transit and at rest. Use sslmode=require (or verify-full with a proper certificate) for PostgreSQL, and HTTPS plus server-side encryption for S3.
  • Keep the database private. If teammates or CI runners connect from other hosts, allow port 5432 only from their addresses (for example sudo ufw allow from your_ci_ip to any port 5432 proto tcp) and add matching hostssl lines to pg_hba.conf. Never expose it to the whole internet.
  • Do not commit state or backups to Git. Add *.tfstate, *.tfstate.* and .terraform/ to .gitignore.

Troubleshooting

Backend configuration changed during init: you edited the backend block. Run terraform init -migrate-state to copy the state to the new location, or terraform init -reconfigure to switch without copying (only when the new location already holds the correct state).

pq: password authentication failed for user "terraform": the password in PG_CONN_STR does not match the role. Reset it with sudo -u postgres psql -c "ALTER ROLE terraform PASSWORD 'your_strong_password';" and update ~/.tfstate.env. Special characters in the password must be URL-encoded in the connection string.

Error acquiring the state lock that does not go away: another run is still active, for example a CI job. Wait for it, or use -lock-timeout. Use terraform force-unlock only for stale locks on backends such as S3, never while another run may be writing.

Conclusion

You moved Terraform state from a local file to a shared PostgreSQL backend with automatic locking, used workspaces, refactored state with moved and removed blocks, and learned how to back up state and how to use the S3 backend with native locking. With state shared and locked, several people and CI pipelines can safely run Terraform against the same infrastructure.

As next steps, run plan and apply from a CI pipeline that has the only write credentials to the backend, schedule the pg_dump backup with your other database backups, and split long-lived environments into separate root modules with their own backend settings.