# AWS with Terraform Tutorial: Terraform Backends (19)

> Store the Terraform or OpenTofu state in an encrypted, versioned S3 bucket with native state locking, and migrate the local state to it.

- Source: https://www.itwonderlab.com/aws-terraform-tutorial-terraform-backends/
- Published: 2026-10-05
- Updated: 2026-10-05
- Author: Javier Ruiz Jiménez (https://www.javierruizjimenez.com/)
- Site: IT Wonder Lab (https://www.itwonderlab.com/)

---

## How to configure a Terraform backend in AWS S3

**Using the Terraform and OpenTofu `s3` backend to keep the state in a versioned, encrypted bucket that a whole team (and a CI/CD pipeline) can share safely.**

Welcome to our tutorial series about [Terraform or OpenTofu on AWS](https://www.itwonderlab.com/tag/aws-terraform-tutorial/). Terraform remembers what it created in a **state file**, `terraform.tfstate`, which by default is saved next to the code. That is fine for learning, but it becomes a problem as soon as the infrastructure is important:

![AWS with Terraform: The Essential Guide, 21 sections. Select a section to open its tutorial.](https://www.itwonderlab.com/media/tutorials/AWS-Terraform-Essentials/ITWL-Tutorials-AWS-Terraform-Essentials-Steps.svg)

- if the file is lost, Terraform no longer knows its resources,
- two people (or a person and a pipeline) running `tofu apply` at the same time can corrupt it,
- the state contains **sensitive data** (database passwords, keys) in plain text and must not be committed to Git.

A **backend** defines where the state is stored. This section moves it to Amazon S3.

### Prerequisites

Read the previous sections of the tutorial, listed in the [series index](#series) at the end of this page. You need an AWS profile (`ditwl_infradmin`, see [Terraform AWS Provider](https://www.itwonderlab.com/aws-terraform-tutorial-terraform-aws-provider/)) with permissions to create an S3 bucket.

### Terraform backends

The backend is configured in the `terraform` block. The most used ones are:

| Backend | State stored in | Locking |
|---|---|---|
| `local` (default) | A file in the project directory | Local file lock |
| `s3` | An Amazon S3 bucket | S3 lock file (or DynamoDB) |
| `gcs` / `azurerm` | Google Cloud Storage / Azure Blob Storage | Yes |
| `http`, `pg`, `kubernetes` | A REST endpoint (for example GitLab), PostgreSQL, a Kubernetes secret | Depends on the backend |
| `cloud` | HCP Terraform (Terraform Cloud) | Yes |

The **S3 backend** is the most common choice on AWS: it is cheap, durable (99.999999999%), supports versioning and encryption, and uses the same IAM permissions as the rest of the infrastructure.

### Step 1: create the bucket

There is a chicken-and-egg problem: the bucket that stores the state must exist before Terraform can use it, so it is created by a **separate, small project** (`backend-bootstrap/`) that keeps its own state locally. It is run once.

```hcl title="backend-bootstrap/main.tf"
terraform {
  required_version = "> 1.5"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

provider "aws" {
  profile = "ditwl_infradmin"
}

data "aws_caller_identity" "current" {}

locals {
  # Bucket names are global: the account number makes it unique
  bucket = "ditwl-tfstate-${data.aws_caller_identity.current.account_id}"
}

resource "aws_s3_bucket" "tfstate" {
  bucket = local.bucket

  # Deleting this bucket would delete the state of all the infrastructure
  lifecycle {
    prevent_destroy = true
  }

  tags = {
    Name = local.bucket
  }
}

# Keep every version of the state: it allows going back after a mistake
resource "aws_s3_bucket_versioning" "tfstate" {
  bucket = aws_s3_bucket.tfstate.id
  versioning_configuration {
    status = "Enabled"
  }
}

# Encrypt the state at rest
resource "aws_s3_bucket_server_side_encryption_configuration" "tfstate" {
  bucket = aws_s3_bucket.tfstate.id
  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm = "AES256"
    }
  }
}

# The state is never public
resource "aws_s3_bucket_public_access_block" "tfstate" {
  bucket                  = aws_s3_bucket.tfstate.id
  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

# Delete old versions after 90 days
resource "aws_s3_bucket_lifecycle_configuration" "tfstate" {
  bucket = aws_s3_bucket.tfstate.id

  rule {
    id     = "expire-old-versions"
    status = "Enabled"
    filter {}

    noncurrent_version_expiration {
      noncurrent_days = 90
    }
  }
}

# Only encrypted connections (HTTPS)
data "aws_iam_policy_document" "tfstate-tls" {
  statement {
    sid       = "DenyInsecureTransport"
    effect    = "Deny"
    actions   = ["s3:*"]
    resources = [aws_s3_bucket.tfstate.arn, "${aws_s3_bucket.tfstate.arn}/*"]

    principals {
      type        = "*"
      identifiers = ["*"]
    }

    condition {
      test     = "Bool"
      variable = "aws:SecureTransport"
      values   = ["false"]
    }
  }
}

resource "aws_s3_bucket_policy" "tfstate" {
  bucket = aws_s3_bucket.tfstate.id
  policy = data.aws_iam_policy_document.tfstate-tls.json

  depends_on = [aws_s3_bucket_public_access_block.tfstate]
}

output "bucket" {
  value = aws_s3_bucket.tfstate.bucket
}
```

```shell
$ cd backend-bootstrap
$ tofu init
$ tofu apply
...
Outputs:

bucket = "ditwl-tfstate-123456789012"
```

### Step 2: configure the backend

In the main project add the `backend` block (inside the same `terraform` block as `required_providers`):

```hcl title="providers.tf"
terraform {
  required_version = "> 1.5"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }

  backend "s3" {
    bucket       = "ditwl-tfstate-123456789012"
    key          = "aws-tutorial/pro/terraform.tfstate"
    region       = "us-east-1"
    profile      = "ditwl_infradmin"
    encrypt      = true
    use_lockfile = true
  }
}
```

- `key` is the path of the state file inside the bucket. Use one key per project and environment (`aws-tutorial/pro/...`, `aws-tutorial/dev/...`) to keep the states small and independent.
- `encrypt = true` asks S3 to encrypt the object.
- `use_lockfile = true` enables **native state locking**: the backend creates a `terraform.tfstate.tflock` object next to the state while a command that changes it runs, and S3 conditional writes guarantee that only one process gets it. No other service is needed.

> [!NOTE]
> Native S3 locking is available in recent versions of OpenTofu and Terraform. Older versions, and teams that prefer it, use a DynamoDB table instead. See "Locking with DynamoDB" below. Check the documentation of your version.

### Step 3: migrate the state

```shell
$ tofu init -migrate-state
Initializing the backend...
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 "s3" backend. ...

  Enter a value: yes

Successfully configured the backend "s3"!
```

The state is now in S3. Verify it and keep the local copy until you are sure that everything works, then delete it (and make sure that `*.tfstate*` is in `.gitignore`):

```shell
$ aws s3 ls s3://ditwl-tfstate-123456789012/aws-tutorial/pro/ --profile ditwl_infradmin
$ tofu plan
$ tofu state list
```

### Test the state locking

Run `tofu apply` in two terminals at the same time. The second one waits and fails with a message like this one, which shows who holds the lock:

```text
Error: Error acquiring the state lock

Lock Info:
  ID:        0d1b2c3d-...
  Operation: OperationTypeApply
  Who:       jruiz@laptop
  Created:   2026-10-05 10:35:12 UTC
```

If a process dies and leaves a stale lock, release it with `tofu force-unlock <ID>`, only after being sure that nobody is running.

### Who can access the state

The state may contain secrets, so access is controlled with IAM. This policy lets a user or pipeline use only the state of this project:

```json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "s3:ListBucket",
      "Resource": "arn:aws:s3:::ditwl-tfstate-123456789012",
      "Condition": { "StringLike": { "s3:prefix": ["aws-tutorial/pro/*"] } }
    },
    {
      "Effect": "Allow",
      "Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"],
      "Resource": "arn:aws:s3:::ditwl-tfstate-123456789012/aws-tutorial/pro/*"
    }
  ]
}
```

The lock file is stored under the same key prefix, so the policy covers it. Read-only users (for example, a pipeline that only runs `tofu plan -lock=false`) need just `s3:GetObject` and `s3:ListBucket`. To also encrypt the contents of the state **before** it is sent to S3, read [How to Encrypt Terraform State with OpenTofu](https://www.itwonderlab.com/terraform-state-file-encryption/).

### Locking with DynamoDB

For versions without native locking, create a table with a `LockID` key and point the backend to it:

```hcl title="backend-bootstrap/main.tf"
resource "aws_dynamodb_table" "tflock" {
  name         = "ditwl-tflock"
  billing_mode = "PAY_PER_REQUEST"
  hash_key     = "LockID"

  attribute {
    name = "LockID"
    type = "S"
  }
}
```

```hcl title="providers.tf"
  backend "s3" {
    # ... the same arguments ...
    dynamodb_table = "ditwl-tflock"
  }
```

The IAM policy of the users also needs `dynamodb:GetItem`, `dynamodb:PutItem` and `dynamodb:DeleteItem` on the table.

### Partial configuration

The `backend` block cannot use Terraform variables (OpenTofu allows variables and locals in recent versions). To avoid repeating values between environments, or to keep them out of the repository, leave them out of the block and pass them when initializing:

```hcl title="backend.hcl"
bucket = "ditwl-tfstate-123456789012"
key    = "aws-tutorial/dev/terraform.tfstate"
region = "us-east-1"
```

```shell
$ tofu init -backend-config=backend.hcl
```

### AWS S3 Cost

A state file is a few kilobytes: storage, versions and requests cost a few cents a month at most. It is the cheapest insurance for your infrastructure.

### Common Questions About Terraform Backends

#### Can I store the state in Git?

No. It contains secrets in plain text, has no locking and merging two versions of a state file is not possible.

#### What if I change the `key` or the bucket?

Terraform sees an empty state and would try to create everything again. Change the configuration and run `tofu init -migrate-state` so that the existing state is copied to the new location.

#### Should I use workspaces for the environments?

Workspaces keep several states for the same code and backend. Many teams prefer one directory (or one `key`) per environment because it is more explicit and avoids applying to the wrong one.

#### How can another project read the outputs of this one?

With the `terraform_remote_state` data source, which only needs read access to the state, or better with a data source of the real resource (for example `aws_vpc`), which does not depend on the other project's state.

### Next Steps

The state is shared and protected. Continue with [Terraform Tools](https://www.itwonderlab.com/aws-terraform-tutorial-terraform-tools/) to validate, lint and document the code.
