Cloud 40 min read

AWS OpenTofu #206: Remote State with S3

Transition from insecure local state to an enterprise-grade remote S3 backend with bucket versioning, server-side encryption, TLS-enforced bucket policies, and seamless state migration.

Mohammad Rizky Prawira portrait
Mohammad Rizky Prawira
terminal Series Guide • Part 6 of 24

AWS + OpenTofu Level 200: Build a Reusable AWS Application Platform

Part of the CLI-first comprehensive learning path from foundational setup to production cloud architectures.

In Module 1, you mastered professional OpenTofu engineering: modular project design, strict version pinning, module composition, dynamic for_each schemas, and zero-downtime resource lifecycles.

However, across all five previous labs, one dangerous shortcut remained: your state was stored locally on your laptop in a plaintext terraform.tfstate file.

In personal sandbox experiments, local state is convenient. In a team or production environment, local state is an operational catastrophe waiting to happen:

  1. The Git Exposure Nightmare: A developer accidentally commits terraform.tfstate to GitHub. Because OpenTofu state stores database credentials, private keys, and environment variables in plaintext, your cloud infrastructure is instantly compromised.
  2. The Out-of-Sync Collision: Two engineers apply changes from their separate laptops. Without a single shared source of truth, one engineer’s state file overwrites the other, causing silent resource orphanages and catastrophic drift.
  3. The Laptop Crash Disaster: A developer’s hard drive fails. Without a backup of the local state file, your team loses the mapping to hundreds of live cloud resources, forcing hours of painful reverse engineering.

Welcome to Module 2: State and Environment Management.

In this lab, you will solve the local state dilemma by architecting an enterprise-grade remote backend using Amazon S3. You will resolve the “chicken-or-egg” bootstrap paradox, build a cryptographically hardened state storage vault with versioning and TLS enforcement, and execute a zero-data-loss state migration using tofu init -migrate-state.


🎯 Learning Objectives

Remote State ArchitectureCryptographic HardeningOperational Workflows
• The risks of local terraform.tfstate• Enforcing AES-256 / KMS encryption• The 2-phase state bootstrap pattern
• The S3 backend contract & limitations• Bucket versioning for instant disaster recovery• Safe state migration (-migrate-state)
• Why backend blocks forbid HCL variables• Denying non-TLS transport via Bucket Policy• Inspecting S3 state versions via AWS CLI

💰 Estimated Lab Cost

ResourceConfigurationFree Tier CostStandard Account (Past Free Tier)
Amazon S3 State Vault1 Versioned S3 Bucket (< 1 MB state data)$0.00$0.00002 / month ($0.023/GB)
S3 API Requests~50 PUT/GET requests during lab$0.00$0.0002 (Negligible fractions of a cent)
KMS / SSE-S3 EncryptionDefault Amazon S3 managed key (SSE-S3)$0.00$0.00 (Always 100% Free)
VPC & Test Security Group1 VPC, 1 Security Group (Managed via S3 state)$0.00$0.00 (Always 100% Free)
Total Estimated CostComplete Hands-On Session (~40 min)$0.00<$0.001 (Fraction of a cent)

[!NOTE] Storing OpenTofu state in Amazon S3 is one of the most cost-effective patterns in all of cloud computing. State files are typically tens of kilobytes, meaning your monthly storage bill will be less than one penny.


🏗️ Architecture: The Centralized State Backend

flowchart TD
    subgraph LocalEngineers["Engineering Team"]
        DevA["Engineer A (Laptop)"]
        DevB["Engineer B (Laptop)"]
        CI["CI/CD Pipeline (GitHub Actions)"]
    end

    subgraph AWSCloud["AWS Cloud (us-east-1)"]
        subgraph BackendVault["Secure S3 State Storage"]
            S3["Amazon S3 State Bucket<br/>• Versioning Enabled<br/>• SSE-S3 / KMS Encrypted<br/>• Block All Public Access<br/>• Deny Insecure HTTP (TLS 1.2+)"]
            V1["v1: Initial State"]
            V2["v2: Added VPC"]
            V3["v3: Active Production State"]
            S3 --> V1
            S3 --> V2
            S3 --> V3
        end

        subgraph LiveInfrastructure["Target AWS Resources"]
            VPC["VPC (10.40.0.0/16)"]
            SG["Security Groups"]
            Workloads["EC2 / ECS / RDS"]
        end
    end

    DevA -->|tofu plan / apply| S3
    DevB -->|tofu plan / apply| S3
    CI -->|Automated PR Validation| S3
    S3 -.->|Maps and Tracks| LiveInfrastructure

How the S3 Backend Operates

When you configure backend "s3", OpenTofu’s runtime loop changes fundamentally:

  1. State Fetch: Before evaluating tofu plan or tofu apply, OpenTofu executes an authenticated HTTPS GetObject request to retrieve the latest state snapshot from S3.
  2. In-Memory Refresh: OpenTofu reconciles the S3 state against the live AWS infrastructure.
  3. Execution: Changes are applied to AWS.
  4. State Commit: OpenTofu serializes the new state in memory and executes an HTTPS PutObject request to S3, creating a new immutable version.
  5. Zero Local Footprint: No terraform.tfstate file is written to your local hard drive.

🔒 The 4 Pillars of a Production S3 State Vault

State files are among the most sensitive digital artifacts in your entire company. If an attacker gains read access to your state file, they can extract every database password, private subnet CIDR, IAM role ARN, and API key in your architecture.

Therefore, an S3 state bucket must adhere to four non-negotiable security controls:

1. Object Versioning (aws_s3_bucket_versioning)

State files can become corrupted due to interrupted network connections or human error. With versioning enabled, AWS retains every past iteration of terraform.tfstate. If a deployment corrupts the state file, rolling back to the previous snapshot takes 10 seconds.

2. Mandatory Server-Side Encryption (aws_s3_bucket_server_side_encryption_configuration)

All state data at rest must be encrypted using either AES256 (SSE-S3) or an AWS KMS Customer Managed Key (aws:kms). Unencrypted state storage violates SOC 2, HIPAA, and ISO 27001 standards.

3. Complete Public Access Block (aws_s3_bucket_public_access_block)

All four S3 public access block flags must be set to true:

  • block_public_acls = true
  • block_public_policy = true
  • ignore_public_acls = true
  • restrict_public_buckets = true

4. TLS-Only Enforcement via Bucket Policy (aws_s3_bucket_policy)

An explicit S3 Bucket Policy must deny any request that does not use HTTPS (aws:SecureTransport: "false"). This ensures plaintext credentials are never transmitted over unencrypted HTTP.


🥚 The “Chicken-or-Egg” Bootstrap Paradox

A classic dilemma faces cloud architects:

“If OpenTofu manages all our AWS infrastructure, can OpenTofu create the S3 bucket that stores its own state?”

If you write a configuration that creates an S3 bucket and put a backend "s3" block at the top of the same file, tofu init will immediately crash:

Error: Failed to get existing workspaces: S3 bucket "my-state-bucket" does not exist.

OpenTofu requires the backend bucket to exist before it can run init.

The 2-Phase Bootstrap Solution

Enterprise DevOps solves this with an elegant two-phase workflow:

sequenceDiagram
    autonumber
    participant Dev as DevOps Engineer
    participant Local as Local State (Bootstrap)
    participant S3 as Amazon S3 Vault
    participant App as Target Application Project

    Note over Dev,S3: Phase 1: Bootstrap Backend Infrastructure
    Dev->>Local: 1. tofu apply (Creates S3 bucket using local state)
    Local->>S3: 2. Provisions hardened S3 bucket in AWS

    Note over Dev,S3: Phase 2: Migrate Application to Remote Backend
    Dev->>App: 3. Add backend "s3" block to app configuration
    Dev->>App: 4. Run "tofu init -migrate-state"
    App->>S3: 5. OpenTofu uploads local state to S3
    Dev->>Local: 6. Local terraform.tfstate is safely backed up / deleted

🛠️ Hands-On Lab: Provisioning Remote State and Migrating

In this lab, you will:

  1. Provision a hardened S3 state bucket using a dedicated bootstrap project.
  2. Build an application project initially using local state.
  3. Wire the application project to the S3 backend and execute a zero-loss state migration.
  4. Verify the state versioning history in AWS using the AWS CLI.

Step 1: Create the Project Structure

Create two separate workspace directories: one for the bootstrap state storage, and one for the application platform:

mkdir -p ~/opentofu-labs/lab-206-remote-state/{bootstrap,app}
cd ~/opentofu-labs/lab-206-remote-state

Step 2: Build the Hardened S3 State Vault (Bootstrap)

Navigate to the bootstrap directory:

cd ~/opentofu-labs/lab-206-remote-state/bootstrap

Create providers.tf:

# bootstrap/providers.tf
terraform {
  required_version = ">= 1.8.0"

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

provider "aws" {
  region = "us-east-1"

  default_tags {
    tags = {
      Project     = "OpenTofu-Level-200"
      Lab         = "Lab-206"
      Role        = "StateBackend"
      ManagedBy   = "OpenTofu"
    }
  }
}

Create main.tf to provision the hardened S3 bucket:

# bootstrap/main.tf

# 1. Unique random string for global S3 bucket naming
resource "random_string" "suffix" {
  length  = 8
  special = false
  upper   = false
}

# 2. S3 State Storage Bucket
resource "aws_s3_bucket" "state_vault" {
  bucket        = "tofu-state-vault-${random_string.suffix.result}"
  force_destroy = true # Set to true for easy lab teardown; false in real production

  lifecycle {
    prevent_destroy = false
  }

  tags = {
    Name = "tofu-state-vault-${random_string.suffix.result}"
  }
}

# 3. Enable Versioning (Crucial for state recovery)
resource "aws_s3_bucket_versioning" "state_versioning" {
  bucket = aws_s3_bucket.state_vault.id

  versioning_configuration {
    status = "Enabled"
  }
}

# 4. Mandatory Server-Side Encryption (AES256)
resource "aws_s3_bucket_server_side_encryption_configuration" "state_encryption" {
  bucket = aws_s3_bucket.state_vault.id

  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm = "AES256"
    }
  }
}

# 5. Block ALL Public Access
resource "aws_s3_bucket_public_access_block" "state_public_block" {
  bucket = aws_s3_bucket.state_vault.id

  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

# 6. Enforce TLS 1.2+ (HTTPS-only) via Bucket Policy
resource "aws_s3_bucket_policy" "enforce_tls" {
  bucket = aws_s3_bucket.state_vault.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid       = "EnforceTLSRequestsOnly"
        Effect    = "Deny"
        Principal = "*"
        Action    = "s3:*"
        Resource = [
          aws_s3_bucket.state_vault.arn,
          "${aws_s3_bucket.state_vault.arn}/*"
        ]
        Condition = {
          Bool = {
            "aws:SecureTransport" = "false"
          }
        }
      }
    ]
  })
}

# 7. Outputs for downstream consumption
output "state_bucket_name" {
  description = "Name of the provisioned S3 state bucket"
  value       = aws_s3_bucket.state_vault.id
}

output "state_bucket_arn" {
  description = "ARN of the S3 state bucket"
  value       = aws_s3_bucket.state_vault.arn
}

Initialize and apply the bootstrap configuration:

tofu init
tofu apply -auto-approve

Sample output:

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

Outputs:

state_bucket_arn = "arn:aws:s3:::tofu-state-vault-7k9p2m4x"
state_bucket_name = "tofu-state-vault-7k9p2m4x"

Save the bucket name to a shell environment variable:

export STATE_BUCKET=$(tofu output -raw state_bucket_name)
echo "Active State Bucket: $STATE_BUCKET"

Step 3: Create the Application Project with Local State

Now, navigate to the app workspace:

cd ~/opentofu-labs/lab-206-remote-state/app

Create providers.tf with NO backend configuration (local state):

# app/providers.tf
terraform {
  required_version = ">= 1.8.0"

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

  # NOTE: Initially no backend block! This defaults to local terraform.tfstate
}

provider "aws" {
  region = "us-east-1"

  default_tags {
    tags = {
      Project     = "OpenTofu-Level-200"
      Lab         = "Lab-206"
      Environment = "dev"
      ManagedBy   = "OpenTofu"
    }
  }
}

Create main.tf representing our core application infrastructure:

# app/main.tf

resource "aws_vpc" "app" {
  cidr_block           = "10.40.0.0/16"
  enable_dns_hostnames = true
  enable_dns_support   = true

  tags = {
    Name = "dev-app-vpc"
  }
}

resource "aws_subnet" "app_subnet" {
  vpc_id            = aws_vpc.app.id
  cidr_block        = "10.40.1.0/24"
  availability_zone = "us-east-1a"

  tags = {
    Name = "dev-app-subnet-1a"
  }
}

resource "aws_security_group" "app_sg" {
  name_prefix = "dev-app-sg-"
  description = "Application tier security group"
  vpc_id      = aws_vpc.app.id

  ingress {
    description = "Allow internal VPC traffic"
    from_port   = 8080
    to_port     = 8080
    protocol    = "tcp"
    cidr_blocks = ["10.40.0.0/16"]
  }

  egress {
    description = "Allow all outbound"
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

output "vpc_id" {
  value = aws_vpc.app.id
}

output "security_group_id" {
  value = aws_security_group.app_sg.id
}

Initialize and apply the application with local state:

tofu init
tofu apply -auto-approve

Verify that local state was created on your machine:

ls -la terraform.tfstate

The file exists on your local disk. If your laptop caught fire right now, your mapping to the VPC and Security Group would be lost forever!


Step 4: The Migration — Wiring the S3 Remote Backend

Now comes the critical step: migrating our live application state into the S3 bucket with zero downtime and zero resource modification.

Open app/providers.tf and add the backend "s3" block. Replace the bucket value with your active bucket name (stored in $STATE_BUCKET):

# app/providers.tf
terraform {
  required_version = ">= 1.8.0"

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

  # Remote State Backend Configuration
  backend "s3" {
    bucket  = "REPLACE_WITH_YOUR_STATE_BUCKET_NAME" # e.g. tofu-state-vault-7k9p2m4x
    key     = "workloads/dev/app.tfstate"
    region  = "us-east-1"
    encrypt = true
  }
}

provider "aws" {
  region = "us-east-1"

  default_tags {
    tags = {
      Project     = "OpenTofu-Level-200"
      Lab         = "Lab-206"
      Environment = "dev"
      ManagedBy   = "OpenTofu"
    }
  }
}

[!TIP] You can update the bucket name programmatically using sed:

sed -i '' "s/REPLACE_WITH_YOUR_STATE_BUCKET_NAME/$STATE_BUCKET/g" providers.tf

Step 5: Execute State Migration (tofu init -migrate-state)

Now, re-initialize OpenTofu. OpenTofu will detect that your backend configuration changed from local to s3:

tofu init -migrate-state

OpenTofu displays an interactive migration prompt:

Initializing the backend...
OpenTofu has detected that the configuration specified for the backend
has changed from "local" to "s3".

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. An existing state was also found in "s3", but
  it is completely empty.

  If you accept, OpenTofu will copy the existing sub-state into the new backend.
  If you reject, OpenTofu will abandon the existing sub-state and initialize the
  new backend as completely empty.

  Enter a value: yes

Type yes and press Enter.

Successfully configured the backend "s3"! OpenTofu will now use this backend
unless the backend configuration changes.

Step 6: Verify Zero-Drift & State Elimination

Run tofu plan to ensure that OpenTofu recognizes all existing resources in the new remote backend:

tofu plan

Output:

aws_vpc.app: Refreshing state... [id=vpc-0123456789abcdef0]
aws_subnet.app_subnet: Refreshing state... [id=subnet-0123456789abcdef0]
aws_security_group.app_sg: Refreshing state... [id=sg-0123456789abcdef0]

No changes. Your infrastructure matches the configuration.

OpenTofu has compared your real infrastructure against your configuration and found
no differences, so no changes are needed.

Now check your directory:

ls -la

You will notice a file named terraform.tfstate.backup. OpenTofu backed up your old local state and stopped writing to terraform.tfstate.

You can now safely remove local state artifacts:

rm -f terraform.tfstate terraform.tfstate.backup

Even with no local .tfstate file present, tofu state list instantly retrieves your resources directly from Amazon S3:

tofu state list
aws_security_group.app_sg
aws_subnet.app_subnet
aws_vpc.app

Step 7: Inspect S3 Versioning & Audit History

Every time you apply changes to your infrastructure, OpenTofu writes a new version of the state file to S3.

Let’s make a small change to trigger a state update. Add a tag to the VPC in app/main.tf:

 resource "aws_vpc" "app" {
   cidr_block           = "10.40.0.0/16"
   enable_dns_hostnames = true
   enable_dns_support   = true
 
   tags = {
     Name = "dev-app-vpc"
+    Tier = "PlatformNetworking"
   }
 }

Apply the change:

tofu apply -auto-approve

Now, query the AWS S3 API using the AWS CLI to view all immutable versions of workloads/dev/app.tfstate:

aws s3api list-object-versions \
  --bucket "$STATE_BUCKET" \
  --prefix "workloads/dev/app.tfstate" \
  --query 'Versions[].{VersionId:VersionId, LastModified:LastModified, Size:Size, IsLatest:IsLatest}' \
  --output table

Output:

-------------------------------------------------------------------------------------
|                                ListObjectVersions                                 |
+----------+---------------------------+-----------------------------------+--------+
| IsLatest |       LastModified        |             VersionId             | Size   |
+----------+---------------------------+-----------------------------------+--------+
|  True    | 2026-09-17T09:15:22.000Z  | 9aBcDeFgHiJkLmNoPqRsTuVwXyZ12345  | 5420   |
|  False   | 2026-09-17T09:10:05.000Z  | 1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d  | 5280   |
+----------+---------------------------+-----------------------------------+--------+

Notice that both versions are preserved in AWS with full cryptographic integrity! If an engineer ever damages the active state file, you can restore any previous version with a single command.


⚠️ Common Gotchas & Troubleshooting

1. Variables in backend "s3" Blocks (The Interpolation Restriction)

Symptom:

Error: Variables not allowed
  on providers.tf line 12:
  12:   bucket = var.state_bucket_name

Variables may not be used here.

Cause: The terraform { backend "s3" { ... } } block is evaluated during the earliest stage of tofu init, before OpenTofu evaluates variables, locals, or data sources.

Fix: Use partial configuration with -backend-config. Store environment-specific backend variables in a separate file:

# backend-dev.hcl
bucket = "tofu-state-vault-7k9p2m4x"
key    = "workloads/dev/app.tfstate"
region = "us-east-1"

Then initialize without hardcoding:

tofu init -backend-config=backend-dev.hcl

2. Access Denied on S3 State Operations

Symptom:

Error: Failed to get existing workspaces: AccessDenied: Access Denied

Cause: Your IAM user or role lacks permissions to perform s3:GetObject, s3:PutObject, or s3:ListBucket on the state bucket, or your requests are violating the aws:SecureTransport TLS condition.

Fix: Ensure your IAM identity has the following minimal policy attached:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "s3:ListBucket",
      "Resource": "arn:aws:s3:::YOUR_BUCKET_NAME"
    },
    {
      "Effect": "Allow",
      "Action": [
        "s3:GetObject",
        "s3:PutObject",
        "s3:DeleteObject"
      ],
      "Resource": "arn:aws:s3:::YOUR_BUCKET_NAME/*"
    }
  ]
}

🧹 Clean Teardown

To cleanly destroy all lab resources, teardown in reverse order: first destroy the application resources, then delete the versioned S3 bucket.

Step 1: Destroy the Application Workload

Inside ~/opentofu-labs/lab-206-remote-state/app:

tofu destroy -auto-approve

Step 2: Empty and Destroy the Versioned S3 Bucket

Because the S3 bucket has versioning enabled, standard deletion will fail if versioned objects remain. Use the AWS CLI to purge all object versions:

aws s3api delete-objects \
  --bucket "$STATE_BUCKET" \
  --delete "$(aws s3api list-object-versions \
    --bucket "$STATE_BUCKET" \
    --query='{Objects: Versions[].{Key:Key,VersionId:VersionId}}' \
    --output json)" 2>/dev/null || true

Now navigate to bootstrap and destroy the storage infrastructure:

cd ~/opentofu-labs/lab-206-remote-state/bootstrap
tofu destroy -auto-approve

Verify clean destruction:

Destroy complete! Resources: 6 destroyed.

📌 Architectural Takeaways

  1. Local state is banned in production: Storing terraform.tfstate on individual laptops invites catastrophic data loss, secret leaks, and unresolvable concurrency drift.
  2. The 4 S3 Security Controls: Every state bucket must enforce Versioning, Server-Side Encryption, Block All Public Access, and HTTPS-Only (TLS 1.2+) Transport.
  3. The 2-Phase Bootstrap Pattern: Create the state bucket using a dedicated bootstrap configuration with local state, then migrate application workloads using tofu init -migrate-state.
  4. Backend blocks reject variables: Use static values or partial configuration with -backend-config=<file>.hcl to dynamically inject bucket names across environments.

In Lab 207, we will solve the final piece of the remote state puzzle: State Locking and Concurrent Changes (preventing simultaneous write collisions using DynamoDB and OpenTofu’s native S3 lock mechanisms).

Related & Recommended Guides

Continue exploring related systems architectures and engineering field notes.