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.
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 ( |
2 |
Environment variables ( |
3 |
The shared credentials files ( |
4 |
The shared configuration files ( |
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 |
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_roleacceptsrole_arn,duration,external_id,policy,policy_arns,session_name,source_identity,tagsandtransitive_tag_keys. Environment variables do not configureassume_role; use the provider block or a named profile (role_arnandrole_session_namein the shared configuration). -
assume_rolecan be repeated to chain roles in order. -
allowed_account_ids(or the mutually exclusiveforbidden_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_identitytakesrole_arn,web_identity_tokenorweb_identity_token_file,session_name,duration,policyandpolicy_arns, and the corresponding environment variables areAWS_ROLE_ARN,AWS_WEB_IDENTITY_TOKEN_FILEandAWS_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:
tagsholds only what you wrote in the resource, andtags_allholds 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, exceptaws_autoscaling_group, which needs its tags written explicitly astagblocks or alaunch_templatetag 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_tagstakeskeys(exact keys) andkey_prefixes, and the equivalent environment variables areTF_AWS_IGNORE_TAGS_KEYSandTF_AWS_IGNORE_TAGS_KEY_PREFIXES. A tag you ignore must not also be set in a resourcetagsmap, 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 exampleterraform 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
regionargument 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 theprovidermeta-argument withregion, 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 |
|---|---|
|
Selects a named profile and the files to read it from |
|
Tune API retries ( |
|
Use FIPS 140 or IPv4/IPv6 dual-stack endpoints |
|
Overrides service endpoints, for LocalStack or a VPC endpoint; the Custom Service Endpoints guide lists the service keys |
|
Disable the startup checks that need the real STS, EC2 metadata or IAM APIs; meant for AWS-compatible emulators, not production |
|
Path-style S3 URLs, needed by some S3-compatible servers |
|
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
Source: aws-actions/configure-aws-credentials, hashicorp/setup-terraform and GitHub Docs — Configuring OpenID Connect in Amazon Web Services.
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
}
Source: aws_s3_bucket, aws_s3_bucket_versioning, aws_s3_bucket_server_side_encryption_configuration and aws_s3_bucket_public_access_block.
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
}
}
Source: Terraform Docs — Backend type: s3.
-
use_lockfile = truemakes Terraform keep a lock file next to the state object while an operation runs. It is opt-in and defaults tofalse. -
DynamoDB locking is deprecated. The
dynamodb_tableanddynamodb_endpointarguments 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 adynamodb_tableis migrated by addinguse_lockfile = true, runningterraform init -reconfigureand, after a while, removing the table argument and the table. -
The identity running Terraform needs
s3:ListBucketon the bucket ands3:GetObject,s3:PutObjecton the state key, pluss3:DeleteObjecton the.tflockobject next to it (it is not needed on the state object itself). The S3 backend page lists the exact policy documents; if you setkms_key_id, addkms:Encrypt,kms:Decryptandkms:GenerateDataKeyon the key. -
The backend block does not accept variables, so a value that differs between environments comes from
-backend-config(partial configuration) rather than fromvar.; 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,profileand the others are configured inside thebackendblock, 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 |
|
Containers on Fargate, task roles, the service and ALB path |
|
EC2 instances and Auto Scaling groups |
EC2 and Auto Scaling, and the compute choice guide |
PostgreSQL |
|
ElastiCache |
|
Buckets |
|
IAM roles and policies |
|
Secrets and keys |
|
Load balancers, DNS and certificates |
|
Logs and metrics |
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
Source: layout conventions from Terraform Docs — Style Guide and AWS Prescriptive Guidance — Code base structure and organization.
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"
}
Source: Terraform Docs — Input Variables and aws_availability_zones.
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_groupand the ElastiCache subnet group when asked, and exposes their names as the outputsdatabase_subnet_group_nameandelasticache_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
albegress rule to the service group completes the path; a rule that opens a port to0.0.0.0/0appears only on the load balancer. -
aws_vpc_security_group_ingress_ruleandaws_vpc_security_group_egress_rulehave replaced inlineingressandegressblocks as the recommended way to manage rules, one rule per resource; mixing both styles on one group makes Terraform fight itself. -
name_prefixwithcreate_before_destroylets a security group be replaced without a name clash. A hard-codednameon 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_gatewayshares 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
}
Source: aws_db_instance and resource documentation source.
-
manage_master_user_password = trueasks RDS to create the master password in Secrets Manager. The blockmaster_user_secretthen exposes the secret’s ARN (secret_arn), which is what the task definition below consumes. Do not also setpassword. 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_versionto just the major version (17) is a prefix: withauto_minor_version_upgradeenabled the provider accepts a prefix and reports the version in use inengine_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_protectionblocks deletion through the API, includingterraform destroy, andskip_final_snapshot = falserequires afinal_snapshot_identifier. They are set from the environment so that development destroys cleanly and production does not. -
The module
terraform-aws-modules/rdswraps 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
}
Source: aws_elasticache_replication_group.
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
}
Source: aws_iam_policy_document, aws_iam_role and aws_iam_role_policy.
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
}
Source: aws_ecs_service, aws_ecs_task_definition, aws_ecs_cluster, aws_lb, aws_lb_target_group and Amazon ECS — Pass Secrets Manager secrets through environment variables.
What to notice:
-
container_definitionsis a JSON string, which is whyjsonencodeis 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
secretsentries use the documentedarn:...:secret:name:json-key:version-stage:version-idform: 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; settingforce_new_deployment = trueon the service forces one. The execution role, not the task role, needssecretsmanager: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 = truewould makeapplywait 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
containerInsightscluster 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
Source: Terraform Docs — terraform plan and terraform apply.
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 |
|---|---|---|
|
6.7.3 |
VPC, subnets in tiers, NAT, route tables, subnet groups, endpoints |
|
7.2.2 |
RDS instances with parameter, option and subnet groups, monitoring role and managed password |
|
5.17.0 |
A bucket with versioning, encryption, public access block and policies as one block |
|
7.6.1 |
ECS clusters, capacity providers, services and task definitions |
|
10.5.1 |
Application and network load balancers with listeners, target groups and rules |
|
1.11.2 |
ElastiCache clusters, replication groups, serverless caches and users |
|
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.0on the VPC module does not mean provider 6, and each module’srequired_providersstates 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
testas the placeholder credentials, and theskip_*arguments stop the provider from calling the real STS and EC2 metadata services.skip_requesting_account_idis listed in the provider documentation for implementations without IAM, STS or metadata APIs; with thestsandiamendpoints 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, usehttp://localhost:4566and adds3_use_path_style = trueto the provider block. -
The documentation states that its
lstk terraformcommand supersedes the oldertflocalwrapper, which is deprecated but still available.lstk terraform initandlstk terraform applygenerate the endpoint overrides in a file namedlocalstack_providers_override.tf(the name can be changed withLSTK_TF_OVERRIDE_FILE_NAME), so the provider block in your configuration stays unchanged;LSTK_TF_CMD=tofuruns OpenTofu instead. With the AWS provider 5.x or later, the S3 endpoint can also come from the environment variableAWS_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
.tflocklock file thatuse_lockfilecreates, 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:
-
Upgrade to the latest 5.x release first. Run
terraform planand fix every deprecation warning and unexpected change. A clean plan on the last 5.x release is the precondition for upgrading. -
Change the constraint from
~> 5.xto~> 6.0and runterraform init -upgradeto download the new version and update.terraform.lock.hcl. -
Run
terraform planagain and work through the differences using the table below. -
Optionally, run
terraform apply -refresh-onlyand then migrate per-Region provider aliases to theregionargument.
| Change in 6.0.0 | What to do |
|---|---|
Provider |
Delete them from the provider block |
Most resources gain an optional top-level |
Nothing is required; existing alias-based configurations keep working. Review any attribute you used called |
|
Remove it, or set it to |
Nullable boolean arguments accept only an empty string, |
Replace |
|
Use |
|
Add |
|
Set the strategy explicitly when |
|
Use |
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 |
Source: Terraform AWS Provider Version 6 Upgrade Guide (published at the registry).
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.
-
Review before you delete. Run
terraform plan -destroyand read the list; a plan that shows resources you do not recognise means you are in the wrong account or workspace.allowed_account_idsin the provider block is the guard rail for this. -
Lift the protections the configuration put in place.
deletion_protectionon the RDS instance andprevent_destroyon a resource makedestroyfail by design. In this page they are only on in production. Change the value, apply, then destroy. -
Empty versioned buckets.
force_destroy = trueon 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. -
Run the destroy.
terraform destroy -var-file=environments/dev.tfvars -var="name_prefix=example" -var="catalog_api_image=PLACEHOLDER"Source: Terraform Docs — terraform destroy.
-
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 theprevent_destroyline, and destroy the bootstrap configuration. -
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.