Terraform on AWS

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages.

Amazon Web Services is the cloud Terraform was used on most for a decade, and the hashicorp/aws provider is the largest provider in the registry: its own documentation lists more than 1,700 resources and more than 600 data sources. This page shows how to use it well. It covers how the provider finds credentials and how to give it the least privilege that works, the tagging and multi-Region features that distinguish version 6, how to store state in S3 with native locking, how to build the Bookshelf scenario on AWS (a network, a container service, a PostgreSQL database, a cache, a covers bucket and the IAM roles that tie them together), when to use the terraform-aws-modules collection instead of writing resources by hand, how to practise against LocalStack, how to upgrade from version 5, what AWS Prescriptive Guidance recommends, and how to clean everything up so that an experiment does not turn into a monthly bill.

The baseline is Terraform 1.17 (1.17.0 was released on October 21, 2026), OpenTofu 1.13 and the AWS provider 6.x (the provider changelog lists 6.70.0 as the latest release on October 21, 2026; version 7 is announced in deprecation notices only). The page was checked against the provider documentation on the provider GitHub repository, the Terraform S3 backend page, the README files of the modules and actions used, the LocalStack documentation and the AWS Prescriptive Guidance guide. It assumes the earlier pages of this section: Providers (provider requirements, aliases and the lock file), Backends and Remote State (backend mechanics in general), Modules and Sensitive Data, Ephemeral Values and Write-Only Arguments. This page does not explain the AWS services themselves; each service has its own page in the AWS reference, and the sections below link to it instead of repeating it. For CloudFormation, the CDK and SAM as alternatives to Terraform on AWS, see Infrastructure as Code on AWS.

All account IDs, bucket names, role ARNs and hostnames are placeholders. The examples use us-east-1 and eu-west-1 as example Regions, and none of them has been applied to a real account for this page; only the LocalStack workflow is meant to be run without an AWS account.

The shape of Terraform on AWS

Three things are different on AWS from the local Docker examples used earlier in this section.

  • Identity is the first problem. There is no single "API key" to put in the provider block. The provider uses the same credential chain as the AWS CLI and the AWS SDKs, and the right source depends on where Terraform runs: a laptop, a CI job, an EC2 instance or an ECS task.

  • Almost everything is Regional, a few things are global, and a few global things live in us-east-1. Version 6 of the provider made Regions much easier to work with; see Multi-Region with the region argument.

  • Resources cost money while they exist. A NAT gateway, a load balancer and an RDS instance bill by the hour whether or not traffic flows. Plan the clean-up before you apply, as in Cleaning up.

The pieces this page builds fit together as follows. The figure is a simplified original drawing of the Bookshelf baseline in this page, not an AWS reference architecture.

flowchart LR user["Browser"] --> alb["Application load balancer (public subnets)"] alb --> catalog["ECS service: catalog-api (private subnets)"] catalog --> rds[("RDS for PostgreSQL (database subnets)")] catalog --> cache[("ElastiCache replication group (cache subnets)")] catalog --> covers[["S3 covers bucket"]] catalog -. "task role" .-> iam["IAM roles"] ci["CI job"] -. "OIDC, assume role" .-> iam tf["Terraform"] --> state[["S3 state bucket with use_lockfile"]]

Source: simplified original drawing of the configuration in this page; the services are described in the AWS reference.

The hashicorp/aws provider

Requirements and version constraints

The provider source address is hashicorp/aws. Constrain it to the major version you have tested, and let the dependency lock file (see Providers) pin the exact release:

terraform {
  required_version = ">= 1.11"

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

provider "aws" {
  region = var.region
}

~> 6.0 allows any 6.x release and blocks 7.0. The required_version of >= 1.11 is not a provider requirement: it is the version in which the Terraform changelog declares S3 native state locking generally available (1.10 introduced it), and the configuration in this page uses it. A Region is required: set it with region, with AWS_REGION or AWS_DEFAULT_REGION, with a profile in the shared configuration file, or, on EC2, from the instance metadata.

How the provider finds credentials

The provider applies these sources in order and stops at the first one that supplies credentials:

Order Source

1

Arguments in the provider block (access_key, secret_key, token, profile, shared_config_files, shared_credentials_files)

2

Environment variables (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN, AWS_PROFILE, AWS_CONFIG_FILE, AWS_SHARED_CREDENTIALS_FILE)

3

The shared credentials files (~/.aws/credentials by default)

4

The shared configuration files (~/.aws/config by default), which can name an assumed role, an IAM Identity Center profile, a web identity token or an external credential process

5

Container credentials: the task role on ECS and CodeBuild, or the pod role (IRSA) on EKS

6

Instance profile credentials from the EC2 instance metadata service, and the Region

The order matches the AWS CLI and the AWS SDK for Go v2. Which source you should use depends on where Terraform runs:

Where Terraform runs Recommended source

A developer laptop

A named profile in ~/.aws/config that uses IAM Identity Center (single sign-on) or assumes a role, selected with AWS_PROFILE. Short-lived credentials, nothing in the repository. See Identity and Access and Getting Started.

GitHub Actions or another OIDC-capable CI system

A web identity: the job exchanges its OIDC token for a role session, with no stored secret. See Authenticating from CI and CI/CD and Automation.

An EC2 instance, an ECS task or a CodeBuild project

The instance profile or task role, with nothing configured in the provider.

A local LocalStack container

Dummy credentials; see LocalStack.

Static access keys (access_key and secret_key in the provider block) are the one source to avoid. The provider documentation warns that hard-coded credentials risk secret leakage if the file is ever committed. The provider block in this page never contains a credential.

Assuming a role

To work in another account, or to separate a read-only role from a deploying role, the provider can assume an IAM role with the credentials it already has. The documentation lists role chaining by repeating the block:

provider "aws" {
  region = var.region

  assume_role {
    role_arn     = "arn:aws:iam::111122223333:role/bookshelf-terraform-deploy"
    session_name = "bookshelf-terraform"
    external_id  = var.deploy_external_id
  }

  # Refuse to run against any other account.
  allowed_account_ids = ["111122223333"]
}
  • assume_role accepts role_arn, duration, external_id, policy, policy_arns, session_name, source_identity, tags and transitive_tag_keys. Environment variables do not configure assume_role; use the provider block or a named profile (role_arn and role_session_name in the shared configuration).

  • assume_role can be repeated to chain roles in order.

  • allowed_account_ids (or the mutually exclusive forbidden_account_ids) is a guard rail: the provider fails if the credentials resolve to an account outside the list. It is the cheapest protection against applying the production configuration with development credentials.

  • assume_role_with_web_identity takes role_arn, web_identity_token or web_identity_token_file, session_name, duration, policy and policy_arns, and the corresponding environment variables are AWS_ROLE_ARN, AWS_WEB_IDENTITY_TOKEN_FILE and AWS_ROLE_SESSION_NAME. This is what GitHub Actions and EKS use.

default_tags and ignore_tags

Tagging is how AWS cost allocation, ownership and automation work, and repeating a tags map on every resource is both noisy and easy to forget. The provider’s default_tags block applies tags to every taggable resource it manages:

provider "aws" {
  region = var.region

  default_tags {
    tags = {
      Application = "bookshelf"
      Environment = var.environment
      ManagedBy   = "terraform"
      Repository  = "github.com/example/bookshelf-infra"
    }
  }

  # Another system (a cost tool, a backup service) writes these tags.
  ignore_tags {
    key_prefixes = ["aws:", "backup:"]
  }
}

resource "aws_s3_bucket" "covers" {
  bucket = "${var.name_prefix}-covers"

  tags = {
    Name = "bookshelf-covers" # merged with the provider defaults
  }
}

output "covers_tags_all" {
  value = aws_s3_bucket.covers.tags_all
}

How it behaves:

  • A resource has two attributes: tags holds only what you wrote in the resource, and tags_all holds the result after merging the provider defaults. A tag on the resource wins over a provider default with the same key.

  • Provider tags can be overridden, but not excluded from a single resource.

  • The feature works with every resource that has tags, except aws_autoscaling_group, which needs its tags written explicitly as tag blocks or a launch_template tag specification.

  • Default tags can also come from environment variables named TF_AWS_DEFAULT_TAGS_<tag_key>; the provider block wins when a tag appears in both.

  • ignore_tags takes keys (exact keys) and key_prefixes, and the equivalent environment variables are TF_AWS_IGNORE_TAGS_KEYS and TF_AWS_IGNORE_TAGS_KEY_PREFIXES. A tag you ignore must not also be set in a resource tags map, or the plan shows a perpetual difference.

Multi-Region with the region argument

Before version 6, working in two Regions meant one provider block per Region, an alias for each, and a provider = argument on every resource that belonged to the second Region. Version 6.0.0 changed that: most resources, data sources and ephemeral resources now have an optional, computed, top-level region argument that defaults to the provider’s Region. The provider block per Region still works and is not deprecated, so existing configurations keep working.

provider "aws" {
  region = "eu-west-1"
}

resource "aws_s3_bucket" "covers" {
  bucket = "bookshelf-covers-eu"
}

# Same provider, different Region: no alias needed in version 6.
resource "aws_s3_bucket" "covers_replica" {
  region = "us-east-1"
  bucket = "bookshelf-covers-us"
}

data "aws_region" "current" {}

output "provider_region" {
  value = data.aws_region.current.region # the name attribute is deprecated
}

What to know about region:

  • Changing the value forces replacement of the resource, because a resource cannot move between Regions. Removing the argument does not force replacement; the Region recorded in state is kept.

  • The value is validated against the partition of the provider’s Region, so a Region in the standard partition cannot be mixed with one in the China or GovCloud partitions.

  • To import a resource in a specific Region, append @<region> to the import ID, for example terraform import aws_vpc.test_vpc vpc-a01106c2@eu-west-1; see Import and Query.

  • Some resources are inherently global (IAM, for instance) or already had a region argument of their own, so not every resource supports it. The provider’s Enhanced Region Support guide lists the exceptions.

  • To migrate from per-Region provider blocks, upgrade to version 6, run terraform apply -refresh-only, and only then replace the provider meta-argument with region, so that state is refreshed before the configuration changes.

A provider alias is still needed when the credentials differ, not just the Region. A second account is the usual case:

provider "aws" {
  region = var.region
}

provider "aws" {
  alias  = "security"
  region = var.region

  assume_role {
    role_arn = "arn:aws:iam::444455556666:role/bookshelf-security-audit"
  }
}

# A resource in the security account uses the alias; everything else uses the default provider.
resource "aws_s3_bucket" "audit_logs" {
  provider = aws.security
  bucket   = "bookshelf-audit-logs"
}

See Providers for how a child module receives an aliased provider with configuration_aliases, and Accounts and Organizations for the multi-account structure these roles assume.

A few other provider arguments worth knowing

Argument What it does

profile, shared_config_files, shared_credentials_files

Selects a named profile and the files to read it from

max_retries, retry_mode

Tune API retries (standard or adaptive); useful against throttling in large accounts

use_fips_endpoint, use_dualstack_endpoint

Use FIPS 140 or IPv4/IPv6 dual-stack endpoints

endpoints

Overrides service endpoints, for LocalStack or a VPC endpoint; the Custom Service Endpoints guide lists the service keys

skip_credentials_validation, skip_metadata_api_check, skip_requesting_account_id, skip_region_validation

Disable the startup checks that need the real STS, EC2 metadata or IAM APIs; meant for AWS-compatible emulators, not production

s3_use_path_style

Path-style S3 URLs, needed by some S3-compatible servers

s3_us_east_1_regional_endpoint

Deprecated in version 6 and removed in version 7

Authenticating from CI

A CI job should hold no AWS secret. GitHub Actions can request an OIDC token for the run, and AWS can be told to trust it: you create an IAM OIDC identity provider for https://token.actions.githubusercontent.com, and an IAM role whose trust policy accepts the audience sts.amazonaws.com and a sub claim naming your repository and, better, a branch or a protected environment. The job then calls aws-actions/configure-aws-credentials to assume the role and runs Terraform with the resulting session credentials. The full workflow, including the plan comment and the gated apply, is in CI/CD and Automation. The condensed version is:

permissions:
  id-token: write   # lets the job request the OIDC token
  contents: read

jobs:
  plan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7

      - uses: hashicorp/setup-terraform@v4
        with:
          terraform_wrapper: false

      - uses: aws-actions/configure-aws-credentials@v6
        with:
          role-to-assume: ${{ vars.AWS_PLAN_ROLE_ARN }}
          aws-region: ${{ vars.AWS_REGION }}

      - run: terraform init -no-color
      - run: terraform plan -no-color -lock-timeout=5m

The action versions here are the current majors found on their repositories when this page was written: actions/checkout v7, hashicorp/setup-terraform v4 (older tutorials use v3) and aws-actions/configure-aws-credentials v6. The role itself is a Terraform resource; the aws_iam_openid_connect_provider resource and a trust policy for it appear in CI/CD and Automation. Use two roles: a read-only role for pull request plans and a deploying role that only the protected branch or environment can assume.

State in S3

The S3 backend stores the state object in a bucket and, since Terraform 1.10, can lock it with a lock object (<key>.tflock) written next to it. General backend behaviour (partial configuration, migrating, workspaces) is in Backends and Remote State; this section is the AWS-specific part.

Creating the state bucket

The bucket must exist before the backend can use it, which is the bootstrap problem: either create it by hand or with the CLI, or manage it with a tiny configuration that keeps its own state locally and is applied once.

# bootstrap/main.tf -- applied once with a local state, per account
resource "aws_s3_bucket" "state" {
  bucket = "bookshelf-tfstate-${data.aws_caller_identity.current.account_id}"

  lifecycle {
    prevent_destroy = true
  }
}

data "aws_caller_identity" "current" {}

resource "aws_s3_bucket_versioning" "state" {
  bucket = aws_s3_bucket.state.id

  versioning_configuration {
    status = "Enabled"
  }
}

resource "aws_s3_bucket_server_side_encryption_configuration" "state" {
  bucket = aws_s3_bucket.state.id

  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm = "aws:kms"
    }
    bucket_key_enabled = true
  }
}

resource "aws_s3_bucket_public_access_block" "state" {
  bucket = aws_s3_bucket.state.id

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

The Terraform documentation recommends enabling bucket versioning so that state can be recovered after an accidental deletion or a human error; the versioning and encryption settings above follow that advice. The bucket name uses the account ID because bucket names are global across all AWS accounts. Bucket settings such as versioning, encryption and public access blocking are separate aws_s3_bucket_* resources here, as the current provider documentation shows; a book-era acl or versioning argument on the bucket resource itself is the pattern to replace (the provider marks acl on aws_s3_bucket as deprecated and points to the separate aws_s3_bucket_acl resource; most buckets need no ACL at all and use aws_s3_bucket_ownership_controls with ACLs disabled). Likewise, an RDS database is the aws_db_instance resource; there is no aws_rds_instance resource type, although some book-era examples use that name. See S3 Object Storage and S3 Security and Static Hosting for what these settings do.

The backend block

terraform {
  backend "s3" {
    bucket       = "bookshelf-tfstate-111122223333"
    key          = "bookshelf/dev/terraform.tfstate"
    region       = "eu-west-1"
    encrypt      = true
    use_lockfile = true
  }
}
  • use_lockfile = true makes Terraform keep a lock file next to the state object while an operation runs. It is opt-in and defaults to false.

  • DynamoDB locking is deprecated. The dynamodb_table and dynamodb_endpoint arguments still work, but the documentation says they will be removed in a future minor version. The S3 and DynamoDB mechanisms can be configured together during a migration, in which case a lock is acquired from both. The change history is in the Terraform changelog: 1.10 introduced the S3 lock and 1.11 made it generally available and deprecated the DynamoDB arguments. A book-era configuration with a dynamodb_table is migrated by adding use_lockfile = true, running terraform init -reconfigure and, after a while, removing the table argument and the table.

  • The identity running Terraform needs s3:ListBucket on the bucket and s3:GetObject, s3:PutObject on the state key, plus s3:DeleteObject on the .tflock object next to it (it is not needed on the state object itself). The S3 backend page lists the exact policy documents; if you set kms_key_id, add kms:Encrypt, kms:Decrypt and kms:GenerateDataKey on the key.

  • The backend block does not accept variables, so a value that differs between environments comes from -backend-config (partial configuration) rather than from var.; OpenTofu can evaluate variables there through early evaluation (see Backends and Remote State).

  • The backend has its own credential arguments and they are separate from the provider’s: assume_role, assume_role_with_web_identity, profile and the others are configured inside the backend block, and are passed as objects (assume_role = { role_arn = "..." }), not as nested blocks.

For the multi-account layout, the S3 backend documentation describes an administrative account that owns the bucket and environment accounts that delegate access through a role, with workspaces stored under a workspace_key_prefix. How environments are split, and why a separate state per environment is usually preferable to workspaces for production, is in Workspaces, Environments and Project Structure.

The Bookshelf baseline on AWS

The configuration that follows builds the production-shaped version of the running scenario: catalog-api runs as an ECS service on Fargate behind an application load balancer, in private subnets of a new VPC, talking to an RDS for PostgreSQL instance, an ElastiCache replication group and an S3 covers bucket. reviews-api and web follow the same pattern as catalog-api and are left out for length. The page does not explain the services; for each one, the AWS reference has a page:

Concern Reference

Network, subnets, NAT, security groups

VPC Networking

Containers on Fargate, task roles, the service and ALB path

ECS and Fargate and ECS Deployments, Scaling and Networking

EC2 instances and Auto Scaling groups

EC2 and Auto Scaling, and the compute choice guide

PostgreSQL

RDS and Aurora

ElastiCache

Caching, Graph and Partner Databases

Buckets

S3 Object Storage

IAM roles and policies

Identity and Access

Secrets and keys

Secrets Manager and KMS

Load balancers, DNS and certificates

Load Balancers and Gateways and Route 53 and Certificates

Logs and metrics

Monitoring and Observability

The ECS service in this baseline uses Fargate. The EC2 alternative differs in the cluster capacity (an Auto Scaling group, a launch template and an aws_ecs_capacity_provider) and in launch_type; see the page on EC2 above, and Choosing a Compute Service before choosing.

Repository layout and variables

bookshelf-infra/
  versions.tf     # terraform and required_providers
  providers.tf    # the aws provider with default_tags
  backend.tf      # the s3 backend
  variables.tf
  network.tf      # VPC (terraform-aws-modules/vpc) and security groups
  data.tf         # RDS, ElastiCache, S3
  service.tf      # ECR repository reference, IAM, ALB, ECS
  outputs.tf
  environments/
    dev.tfvars
    prod.tfvars

A larger repository would split the network, data and service layers into modules or separate root configurations; that refactor is the subject of Modules and Workspaces, Environments and Project Structure.

variable "region" {
  type    = string
  default = "eu-west-1"
}

variable "environment" {
  type        = string
  description = "dev or prod"

  validation {
    condition     = contains(["dev", "prod"], var.environment)
    error_message = "environment must be dev or prod."
  }
}

variable "name_prefix" {
  type        = string
  description = "Prefix for globally unique names such as bucket names"
}

variable "catalog_api_image" {
  type        = string
  description = "Container image for catalog-api, for example an ECR image URI with a digest"
}

variable "db_engine_version" {
  type    = string
  default = "17"
}

locals {
  name = "bookshelf-${var.environment}"
  azs  = slice(data.aws_availability_zones.available.names, 0, 2)
}

data "aws_availability_zones" "available" {
  state = "available"
}

No password variable exists. The database password is created and held by AWS, as shown below, so it never appears in a .tfvars file or a variable.

Network

The VPC module from the terraform-aws-modules collection creates the VPC, three tiers of subnets, route tables, the internet gateway, a NAT gateway and the subnet groups that RDS and ElastiCache need. Writing these by hand takes dozens of resources; see the module section for when that trade-off is right.

module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "~> 6.0"

  name = local.name
  cidr = "10.20.0.0/16"
  azs  = local.azs

  public_subnets      = ["10.20.0.0/24", "10.20.1.0/24"]
  private_subnets     = ["10.20.10.0/24", "10.20.11.0/24"]
  database_subnets    = ["10.20.20.0/24", "10.20.21.0/24"]
  elasticache_subnets = ["10.20.30.0/24", "10.20.31.0/24"]

  create_database_subnet_group    = true
  create_elasticache_subnet_group = true

  enable_nat_gateway = true
  single_nat_gateway = var.environment == "dev" # one NAT in dev, see the cost note below
}

resource "aws_security_group" "alb" {
  name_prefix = "${local.name}-alb-"
  vpc_id      = module.vpc.vpc_id

  lifecycle {
    create_before_destroy = true
  }
}

resource "aws_security_group" "service" {
  name_prefix = "${local.name}-catalog-"
  vpc_id      = module.vpc.vpc_id

  lifecycle {
    create_before_destroy = true
  }
}

resource "aws_security_group" "database" {
  name_prefix = "${local.name}-db-"
  vpc_id      = module.vpc.vpc_id

  lifecycle {
    create_before_destroy = true
  }
}

resource "aws_security_group" "cache" {
  name_prefix = "${local.name}-cache-"
  vpc_id      = module.vpc.vpc_id

  lifecycle {
    create_before_destroy = true
  }
}

# Rules are separate resources: the inline ingress and egress blocks of aws_security_group conflict with them.
resource "aws_vpc_security_group_ingress_rule" "alb_http" {
  security_group_id = aws_security_group.alb.id
  cidr_ipv4         = "0.0.0.0/0"
  ip_protocol       = "tcp"
  from_port         = 80
  to_port           = 80
}

resource "aws_vpc_security_group_egress_rule" "alb_to_service" {
  security_group_id            = aws_security_group.alb.id
  referenced_security_group_id = aws_security_group.service.id
  ip_protocol                  = "tcp"
  from_port                    = 8080
  to_port                      = 8080
}

resource "aws_vpc_security_group_ingress_rule" "service_from_alb" {
  security_group_id            = aws_security_group.service.id
  referenced_security_group_id = aws_security_group.alb.id
  ip_protocol                  = "tcp"
  from_port                    = 8080
  to_port                      = 8080
}

resource "aws_vpc_security_group_egress_rule" "service_out" {
  security_group_id = aws_security_group.service.id
  cidr_ipv4         = "0.0.0.0/0" # ECR, Secrets Manager and S3 are reached through the NAT gateway
  ip_protocol       = "-1"
}

resource "aws_vpc_security_group_ingress_rule" "database_from_service" {
  security_group_id            = aws_security_group.database.id
  referenced_security_group_id = aws_security_group.service.id
  ip_protocol                  = "tcp"
  from_port                    = 5432
  to_port                      = 5432
}

resource "aws_vpc_security_group_ingress_rule" "cache_from_service" {
  security_group_id            = aws_security_group.cache.id
  referenced_security_group_id = aws_security_group.service.id
  ip_protocol                  = "tcp"
  from_port                    = 6379
  to_port                      = 6379
}
  • The module creates the aws_db_subnet_group and the ElastiCache subnet group when asked, and exposes their names as the outputs database_subnet_group_name and elasticache_subnet_group_name, used below.

  • Each security group allows traffic only from the group in front of it (the database accepts port 5432 only from the service group), which is the pattern the VPC page describes. The alb egress rule to the service group completes the path; a rule that opens a port to 0.0.0.0/0 appears only on the load balancer.

  • aws_vpc_security_group_ingress_rule and aws_vpc_security_group_egress_rule have replaced inline ingress and egress blocks as the recommended way to manage rules, one rule per resource; mixing both styles on one group makes Terraform fight itself.

  • name_prefix with create_before_destroy lets a security group be replaced without a name clash. A hard-coded name on a group that other resources depend on is a common source of failed updates.

  • A NAT gateway costs money for every hour it exists and for every gigabyte it processes. single_nat_gateway shares one across the Availability Zones, which is acceptable for development and a single point of failure for production.

Database: RDS for PostgreSQL with a managed password

resource "aws_db_instance" "postgres" {
  identifier = "${local.name}-postgres"

  engine         = "postgres"
  engine_version = var.db_engine_version
  instance_class = var.environment == "prod" ? "db.m7g.large" : "db.t4g.micro"

  allocated_storage = 20
  storage_encrypted = true

  db_name  = "catalog"
  username = "catalog_admin"

  # AWS generates the master password, stores it in Secrets Manager and can rotate it.
  # It is never in the configuration, the variables or this file; the secret ARN is.
  manage_master_user_password = true

  db_subnet_group_name   = module.vpc.database_subnet_group_name
  vpc_security_group_ids = [aws_security_group.database.id]

  multi_az                = var.environment == "prod"
  backup_retention_period = var.environment == "prod" ? 14 : 1

  deletion_protection       = var.environment == "prod"
  skip_final_snapshot       = var.environment != "prod"
  final_snapshot_identifier = var.environment == "prod" ? "${local.name}-postgres-final" : null
}
  • manage_master_user_password = true asks RDS to create the master password in Secrets Manager. The block master_user_secret then exposes the secret’s ARN (secret_arn), which is what the task definition below consumes. Do not also set password. This pattern keeps the password out of the plan and, for the managed secret, out of state; the alternatives (ephemeral and write-only arguments) are in Sensitive Data, Ephemeral Values and Write-Only Arguments.

  • Setting engine_version to just the major version (17) is a prefix: with auto_minor_version_upgrade enabled the provider accepts a prefix and reports the version in use in engine_version_actual. Pin the full version if you want deterministic plans. Check the RDS supported versions in the RDS reference, because supported engine versions change.

  • deletion_protection blocks deletion through the API, including terraform destroy, and skip_final_snapshot = false requires a final_snapshot_identifier. They are set from the environment so that development destroys cleanly and production does not.

  • The module terraform-aws-modules/rds wraps this resource together with the parameter group and option group, and is the usual choice when you also need those; see the module section below.

ElastiCache

resource "aws_elasticache_replication_group" "cache" {
  replication_group_id = "${local.name}-cache"
  description          = "Bookshelf catalog-api cache"

  engine         = "valkey"
  engine_version = "8.0"
  node_type      = "cache.t4g.micro"
  port           = 6379

  num_cache_clusters         = var.environment == "prod" ? 2 : 1
  automatic_failover_enabled = var.environment == "prod"

  subnet_group_name  = module.vpc.elasticache_subnet_group_name
  security_group_ids = [aws_security_group.cache.id]

  at_rest_encryption_enabled = true
  transit_encryption_enabled = true
}

engine accepts redis (the default) or valkey. Valkey is the open-source Redis fork, and the ElastiCache reference describes the engines and serverless caches. Check the Valkey versions that ElastiCache currently supports before copying the engine_version; the provider requires the major and minor version (for example 8.0) for versions 7 and later. The Redis connection string uses the replication group’s primary_endpoint_address. Version 6 of the provider removed the default of auth_token_update_strategy, and tightened validation of boolean arguments such as at_rest_encryption_enabled, which must be true, false or an empty string and not 0 or 1.

The covers bucket

resource "aws_s3_bucket" "covers" {
  bucket        = "${var.name_prefix}-${local.name}-covers"
  force_destroy = var.environment != "prod" # a non-empty bucket cannot otherwise be destroyed
}

resource "aws_s3_bucket_public_access_block" "covers" {
  bucket = aws_s3_bucket.covers.id

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

resource "aws_s3_bucket_versioning" "covers" {
  bucket = aws_s3_bucket.covers.id

  versioning_configuration {
    status = "Enabled"
  }
}

resource "aws_s3_bucket_server_side_encryption_configuration" "covers" {
  bucket = aws_s3_bucket.covers.id

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

The bucket stays private. Customers see covers through the application, or through CloudFront with an origin access control (CloudFront CDN), never through a public ACL. The version 6 upgrade guide notes that the aws_s3_bucket data source now has bucket_region, and that the region argument of the aws_s3_bucket resource is now the enhanced Region argument; do not use it to mean "the bucket’s Region" in old code that read it.

IAM: execution role and task role

ECS uses two roles: an execution role that the ECS agent assumes to pull the image, write logs and fetch secrets, and a task role that the application assumes while it runs. Keep them separate (the ECS page explains why).

data "aws_iam_policy_document" "ecs_tasks_assume" {
  statement {
    actions = ["sts:AssumeRole"]

    principals {
      type        = "Service"
      identifiers = ["ecs-tasks.amazonaws.com"]
    }
  }
}

resource "aws_iam_role" "execution" {
  name               = "${local.name}-catalog-execution"
  assume_role_policy = data.aws_iam_policy_document.ecs_tasks_assume.json
}

resource "aws_iam_role_policy_attachment" "execution_managed" {
  role       = aws_iam_role.execution.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}

data "aws_iam_policy_document" "read_db_secret" {
  statement {
    actions   = ["secretsmanager:GetSecretValue"]
    resources = [aws_db_instance.postgres.master_user_secret[0].secret_arn]
  }
}

resource "aws_iam_role_policy" "execution_secret" {
  name   = "read-db-secret"
  role   = aws_iam_role.execution.id
  policy = data.aws_iam_policy_document.read_db_secret.json
}

resource "aws_iam_role" "task" {
  name               = "${local.name}-catalog-task"
  assume_role_policy = data.aws_iam_policy_document.ecs_tasks_assume.json
}

data "aws_iam_policy_document" "covers_access" {
  statement {
    actions   = ["s3:GetObject", "s3:PutObject"]
    resources = ["${aws_s3_bucket.covers.arn}/*"]
  }

  statement {
    actions   = ["s3:ListBucket"]
    resources = [aws_s3_bucket.covers.arn]
  }
}

resource "aws_iam_role_policy" "task_covers" {
  name   = "covers-access"
  role   = aws_iam_role.task.id
  policy = data.aws_iam_policy_document.covers_access.json
}

Policies are written with the aws_iam_policy_document data source rather than with a heredoc JSON string or jsonencode: Terraform validates the structure at plan time and the policy reads as HCL. The task role gets object permissions on the covers bucket and nothing else. If the database were reached with IAM authentication, the corresponding rds-db:connect permission would be added here as well.

Load balancer and ECS service on Fargate

resource "aws_lb" "web" {
  name               = "${local.name}-alb"
  load_balancer_type = "application"
  subnets            = module.vpc.public_subnets
  security_groups    = [aws_security_group.alb.id]
}

resource "aws_lb_target_group" "catalog" {
  name        = "${local.name}-catalog"
  port        = 8080
  protocol    = "HTTP"
  vpc_id      = module.vpc.vpc_id
  target_type = "ip" # required for the awsvpc network mode used by Fargate

  health_check {
    path    = "/health"
    matcher = "200"
  }
}

resource "aws_lb_listener" "http" {
  load_balancer_arn = aws_lb.web.arn
  port              = 80
  protocol          = "HTTP"

  default_action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.catalog.arn
  }
}

resource "aws_cloudwatch_log_group" "catalog" {
  name              = "/bookshelf/${var.environment}/catalog-api"
  retention_in_days = 14
}

resource "aws_ecs_cluster" "main" {
  name = local.name

  setting {
    name  = "containerInsights"
    value = "enabled"
  }
}

resource "aws_ecs_task_definition" "catalog" {
  family                   = "${local.name}-catalog"
  requires_compatibilities = ["FARGATE"]
  network_mode             = "awsvpc"
  cpu                      = 512
  memory                   = 1024
  execution_role_arn       = aws_iam_role.execution.arn
  task_role_arn            = aws_iam_role.task.arn

  container_definitions = jsonencode([
    {
      name      = "catalog-api"
      image     = var.catalog_api_image
      essential = true

      portMappings = [{ containerPort = 8080, protocol = "tcp" }]

      environment = [
        { name = "DB_HOST", value = aws_db_instance.postgres.address },
        { name = "DB_NAME", value = "catalog" },
        { name = "REDIS_HOST", value = aws_elasticache_replication_group.cache.primary_endpoint_address },
        { name = "COVERS_BUCKET", value = aws_s3_bucket.covers.bucket },
      ]

      # The ARN:json-key:version-stage:version-id form selects one key of the JSON secret.
      secrets = [
        { name = "DB_USER", valueFrom = "${aws_db_instance.postgres.master_user_secret[0].secret_arn}:username::" },
        { name = "DB_PASSWORD", valueFrom = "${aws_db_instance.postgres.master_user_secret[0].secret_arn}:password::" },
      ]

      logConfiguration = {
        logDriver = "awslogs"
        options = {
          "awslogs-group"         = aws_cloudwatch_log_group.catalog.name
          "awslogs-region"        = var.region
          "awslogs-stream-prefix" = "catalog"
        }
      }
    }
  ])
}

resource "aws_ecs_service" "catalog" {
  name            = "catalog-api"
  cluster         = aws_ecs_cluster.main.id
  task_definition = aws_ecs_task_definition.catalog.arn
  desired_count   = var.environment == "prod" ? 2 : 1
  launch_type     = "FARGATE"

  network_configuration {
    subnets         = module.vpc.private_subnets
    security_groups = [aws_security_group.service.id]
  }

  load_balancer {
    target_group_arn = aws_lb_target_group.catalog.arn
    container_name   = "catalog-api"
    container_port   = 8080
  }

  # Wait for the listener before registering targets, or the service creation can fail.
  depends_on = [aws_lb_listener.http]

  lifecycle {
    ignore_changes = [desired_count] # Application Auto Scaling may change it
  }
}

output "alb_dns_name" {
  value = aws_lb.web.dns_name
}

What to notice:

  • container_definitions is a JSON string, which is why jsonencode is used. The keys inside it (portMappings, logConfiguration) are the ECS API’s camel-case names, not Terraform argument names, and Terraform does not validate them.

  • The secrets entries use the documented arn:...:secret:name:json-key:version-stage:version-id form: the trailing :: selects the current version. Secrets are injected when the task starts, so a rotated password is only picked up after a new deployment; setting force_new_deployment = true on the service forces one. The execution role, not the task role, needs secretsmanager:GetSecretValue, which is why the policy above is attached to it.

  • The listener serves plain HTTP to keep the example short. A real deployment terminates TLS with an ACM certificate and an HTTPS listener, and redirects port 80; see Route 53 and Certificates.

  • lifecycle { ignore_changes = [desired_count] } is the documented pattern for letting Application Auto Scaling own the count after the initial value. wait_for_steady_state = true would make apply wait until the service is stable; it is off by default, so a successful apply does not by itself prove the tasks are healthy.

  • The service is in private subnets without public IPs; the tasks reach ECR, Secrets Manager and CloudWatch Logs through the NAT gateway, or through interface VPC endpoints if you add them (more private and often cheaper at volume).

  • The containerInsights cluster setting is the argument that turns on Container Insights. The provider’s cluster documentation lists it as the only valid setting name.

Outputs and applying

output "rds_secret_arn" {
  value       = aws_db_instance.postgres.master_user_secret[0].secret_arn
  description = "Secrets Manager secret that holds the RDS master credentials (the ARN, not the value)"
}

output "covers_bucket" {
  value = aws_s3_bucket.covers.bucket
}
export AWS_PROFILE=bookshelf-dev          # a named SSO or assume-role profile
terraform init -backend-config="bucket=bookshelf-tfstate-111122223333"
terraform plan -var-file=environments/dev.tfvars -var="name_prefix=example" -var="catalog_api_image=111122223333.dkr.ecr.eu-west-1.amazonaws.com/catalog-api@sha256:PLACEHOLDER" -out=tfplan
terraform apply tfplan

Create the container image repository with aws_ecr_repository (see Container Registry) in a separate small configuration applied first, because the service cannot start until the image exists. The catalog_api_image placeholder above stands for the image you push there.

The terraform-aws-modules collection

terraform-aws-modules is a community-maintained set of Terraform modules for AWS, published on the public registry under the terraform-aws-modules namespace. It is not an AWS or HashiCorp product, but it is the most widely used source of reusable AWS modules and its authors maintain the main ones against the current AWS provider. The versions below were the latest releases found when this page was written:

Module Latest release checked Use it for

terraform-aws-modules/vpc/aws

6.7.3

VPC, subnets in tiers, NAT, route tables, subnet groups, endpoints

terraform-aws-modules/rds/aws

7.2.2

RDS instances with parameter, option and subnet groups, monitoring role and managed password

terraform-aws-modules/s3-bucket/aws

5.17.0

A bucket with versioning, encryption, public access block and policies as one block

terraform-aws-modules/ecs/aws

7.6.1

ECS clusters, capacity providers, services and task definitions

terraform-aws-modules/alb/aws

10.5.1

Application and network load balancers with listeners, target groups and rules

terraform-aws-modules/elasticache/aws

1.11.2

ElastiCache clusters, replication groups, serverless caches and users

terraform-aws-modules/iam/aws

6.8.2

Roles, policies, users, groups and OIDC providers as sub-modules

Source: terraform-aws-modules on GitHub and the release pages of each repository, checked on October 23, 2026.

When to use a module and when to write resources:

  • Use the module when the resource group is large, undistinguished and well covered, such as the VPC. Eight resource types and the route tables between them are exactly the code you do not want to maintain.

  • Write resources when the module’s input surface is larger than the problem, when you need an argument it does not expose, or when a short, readable configuration helps reviewers more than a dependency. The ECS and RDS blocks in this page are short enough to read directly.

  • Pin the version and read the release notes before upgrading. Module major versions track provider majors: the version numbers move independently of the provider, so ~> 6.0 on the VPC module does not mean provider 6, and each module’s required_providers states the provider constraint it needs.

  • The registry module page lists inputs, outputs and the examples folder; the examples are the quickest way to see a supported usage. How modules are published, versioned and consumed in general is in Modules and Module Development and Publishing.

LocalStack

LocalStack emulates AWS APIs in a single container, so you can run the provider against an S3 bucket, an IAM role or an SQS queue without an account, a bill or an internet connection. It is a development and test tool, not a substitute for a real account: coverage of a service varies, and anything that needs real AWS behaviour (an RDS instance actually accepting connections, for example) needs the paid editions or a real account. See Testing for LocalStack as a test layer, and Backends and Remote State for the S3 backend pointed at it.

provider "aws" {
  access_key = "test"
  secret_key = "test"
  region     = "us-east-1"

  skip_credentials_validation = true
  skip_metadata_api_check     = true
  skip_requesting_account_id  = true

  endpoints {
    s3  = "http://s3.localhost.localstack.cloud:4566"
    iam = "http://localhost:4566"
    sqs = "http://localhost:4566"
    sts = "http://localhost:4566"
  }
}

resource "aws_s3_bucket" "covers" {
  bucket = "bookshelf-covers-local"
}

resource "aws_sqs_queue" "cover_jobs" {
  name = "bookshelf-cover-jobs"
}
  • The LocalStack documentation uses test as the placeholder credentials, and the skip_* arguments stop the provider from calling the real STS and EC2 metadata services. skip_requesting_account_id is listed in the provider documentation for implementations without IAM, STS or metadata APIs; with the sts and iam endpoints set it is optional.

  • The recommended S3 endpoint is the virtual-host form s3.localhost.localstack.cloud:4566. If that name does not resolve on your machine, use http://localhost:4566 and add s3_use_path_style = true to the provider block.

  • The documentation states that its lstk terraform command supersedes the older tflocal wrapper, which is deprecated but still available. lstk terraform init and lstk terraform apply generate the endpoint overrides in a file named localstack_providers_override.tf (the name can be changed with LSTK_TF_OVERRIDE_FILE_NAME), so the provider block in your configuration stays unchanged; LSTK_TF_CMD=tofu runs OpenTofu instead. With the AWS provider 5.x or later, the S3 endpoint can also come from the environment variable AWS_ENDPOINT_URL_S3.

  • Start the container as shown in Backends and Remote State, which also explains the S3 backend settings for it. The LocalStack documentation does not say whether it enforces the .tflock lock file that use_lockfile creates, so confirm locking yourself by running two applies at once.

Upgrading from version 5 to version 6

Version 6.0.0 is a major release. The upgrade guide recommends this order, and it is the same procedure for any provider major:

  1. Upgrade to the latest 5.x release first. Run terraform plan and fix every deprecation warning and unexpected change. A clean plan on the last 5.x release is the precondition for upgrading.

  2. Change the constraint from ~> 5.x to ~> 6.0 and run terraform init -upgrade to download the new version and update .terraform.lock.hcl.

  3. Run terraform plan again and work through the differences using the table below.

  4. Optionally, run terraform apply -refresh-only and then migrate per-Region provider aliases to the region argument.

Change in 6.0.0 What to do

Provider endpoints for OpsWorks, SimpleDB and Worklink removed (endpoints.opsworks, endpoints.simpledb, endpoints.sdb, endpoints.worklink)

Delete them from the provider block

Most resources gain an optional top-level region

Nothing is required; existing alias-based configurations keep working. Review any attribute you used called region on S3 (use bucket_region)

s3_us_east_1_regional_endpoint deprecated (the global S3 endpoint)

Remove it, or set it to regional; it is removed in 7.0

Nullable boolean arguments accept only an empty string, true or false

Replace 0 and 1 in the affected arguments, such as at_rest_encryption_enabled and auto_minor_version_upgrade on aws_elasticache_replication_group, preserve_client_ip on target groups and several launch template arguments

aws_region data source: name deprecated

Use region (shown in the example earlier)

aws_ami data source with most_recent = true

Add owners or a filter on image-id or owner-id; the missing filter is now an error, not a warning

aws_elasticache_replication_group: auth_token_update_strategy has no default; an uppercase engine is deprecated

Set the strategy explicitly when auth_token is used; write engine in lowercase

aws_s3_bucket and its data source: bucket_region added

Use bucket_region for the bucket’s location; region now means the Enhanced Region argument

Services removed or deprecated: OpsWorks Stacks and Worklink removed, SimpleDB support removed, Elastic Transcoder and CloudWatch Evidently deprecated

Move to the replacement service or remove the resources

The guide also has one section per affected resource and data source (about seventy), so read the headings for the resources your configuration uses rather than the whole document. The version 5 configuration of a book such as the one by Hafner pins hashicorp/aws ~> 5.0; the changes above are what moving it forward involves. The complete list of deprecations and removals across Terraform, OpenTofu and the providers is collected in What Has Changed and Migration.

AWS Prescriptive Guidance

AWS Prescriptive Guidance publishes a guide, "Best practices for using the Terraform AWS Provider", written by an AWS author and split into five sections. The recommendations that matter most, summarised, map to this page as follows:

Guide section What it covers and where this page applies it

Security

Least-privilege IAM, short-lived credentials, and keeping secrets out of configuration and state; see the authentication, the managed RDS password and the IAM roles above

Backends

Remote state in S3 with versioning, encryption and locking; see State in S3

Code base structure and organization

A consistent file layout, modules and environment separation; see the layout section and Workspaces, Environments and Project Structure

AWS Provider version management

Constrain the provider major, commit the lock file, and test upgrades; see the version constraint and the upgrade section

Community modules

Evaluate, pin and review community modules before adopting them; see the module section

AWS also publishes guidance on the Well-Architected Framework pillars (see Governance, Security and Compliance) that the tagging, encryption and network design here are meant to support. The consolidated cross-cloud checklist is in Best Practices and Troubleshooting, and the static analysis that catches most of these issues before a plan is in Code Quality and Security Scanning.

Cleaning up

Cloud resources keep billing until they are destroyed. After an experiment, destroy everything you created, in this order.

  1. Review before you delete. Run terraform plan -destroy and read the list; a plan that shows resources you do not recognise means you are in the wrong account or workspace. allowed_account_ids in the provider block is the guard rail for this.

  2. Lift the protections the configuration put in place. deletion_protection on the RDS instance and prevent_destroy on a resource make destroy fail by design. In this page they are only on in production. Change the value, apply, then destroy.

  3. Empty versioned buckets. force_destroy = true on the covers bucket makes Terraform delete all objects and versions so the bucket can go. Without it, a non-empty bucket makes the destroy fail.

  4. Run the destroy.

    terraform destroy -var-file=environments/dev.tfvars -var="name_prefix=example" -var="catalog_api_image=PLACEHOLDER"
  5. Destroy the state bucket last. The state bucket has prevent_destroy, versioning and encryption by design. Once every configuration that used it is gone, empty it (including old versions) with the console or the AWS CLI, remove the prevent_destroy line, and destroy the bootstrap configuration.

  6. Check for what Terraform does not own. Look for the log group, the ECR repository you created separately, the CloudWatch alarms, any manual snapshots, any secret that is in its recovery window, and Elastic IP addresses. Cost Explorer and the billing console show anything still charging; see Cost Management and Support.

The cost drivers in this baseline are, in rough order: the NAT gateway (hourly plus data), the RDS instance, the ALB, the Fargate tasks and the ElastiCache nodes. In LocalStack none of these exist outside the container; docker stop removes them.