Terraform with AWS
Terraform against AWS in practice. Provider configuration, authentication without long-lived keys, multi-account patterns, and tagging.
The AWS provider is the most used Terraform provider by a wide margin. The mechanics of writing resources are covered in the pages below. This page is about the decisions that surround them: how Terraform authenticates, how you handle more than one region or account, and the conventions that stop an estate becoming unmanageable.
Provider configuration
terraform {
required_version = ">= 1.9"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = var.region
default_tags {
tags = {
Project = var.project
Environment = var.environment
ManagedBy = "terraform"
}
}
}
Two things worth doing from the start.
Pin the provider version with a pessimistic constraint. AWS provider major versions carry breaking changes. The v4 release split aws_s3_bucket inline blocks into separate resources, which broke a great many configurations that had not pinned.
Use default_tags. Every resource that supports tagging gets these automatically, with no repetition. It is the cheapest way to make cost allocation and ownership work, and retrofitting tags across an existing estate is tedious.
Authentication
Terraform uses the standard AWS credential chain, so it picks up whatever the AWS CLI would. In order of preference:
OIDC federation in CI: the right answer for pipelines. No stored credentials at all:
provider "aws" {
region = "eu-west-1"
assume_role {
role_arn = "arn:aws:iam::111122223333:role/TerraformDeploy"
session_name = "terraform-ci"
}
}
The pipeline exchanges a signed token for temporary credentials, and the role's trust policy restricts which repository and branch may do so. Nothing long-lived exists to leak or rotate.
A named profile locally:
export AWS_PROFILE=acme-staging
terraform plan
Assume-role in the provider block, which is how one configuration manages several accounts.
What not to do: hardcode access_key and secret_key in the provider block. They end up in version control, and they are unnecessary in every environment where Terraform actually runs.
Always confirm which identity you are using before applying:
aws sts get-caller-identity
Pointing at the wrong account is the most expensive Terraform mistake available, and nothing in the plan output warns you.
Multiple regions and accounts
A provider block covers one region and one account. For more, use aliases:
provider "aws" {
region = "eu-west-1"
}
provider "aws" {
alias = "us_east_1"
region = "us-east-1"
}
# CloudFront certificates must live in us-east-1
resource "aws_acm_certificate" "cdn" {
provider = aws.us_east_1
domain_name = var.domain
validation_method = "DNS"
}
That ACM example is the one almost everyone meets. CloudFront only accepts certificates from us-east-1 regardless of where the rest of the stack lives.
For modules that need a non-default provider, pass it explicitly:
module "cdn" {
source = "./modules/cdn"
providers = { aws = aws.us_east_1 }
}
Modules must never declare their own provider block. They declare required_providers and inherit configuration from the root.
Structuring an AWS estate
The pattern that scales:
environments/
├── production/ # own backend key, own state, own credentials
├── staging/
└── dev/
modules/
├── network/
├── application/
└── data/
Each environment is a root module calling shared modules with different values. State is separate per environment, so a staging mistake cannot touch production state, and credentials can be scoped so a staging pipeline cannot reach production at all.
Split by change frequency, not just by environment. Networking changes rarely and is shared; application infrastructure changes constantly. Keeping them in one state means every deploy plans against the whole VPC and a mistake has a wider blast radius. Put the VPC in its own configuration and expose IDs as outputs.
Reading values from elsewhere
To consume another configuration's output, terraform_remote_state works but couples the two tightly and requires read access to state, which contains secrets.
A looser alternative is publishing to SSM Parameter Store and reading it back:
data "aws_ssm_parameter" "vpc_id" {
name = "/platform/network/vpc-id"
}
Also stop hardcoding AMI IDs. AWS publishes current images as public SSM parameters:
data "aws_ssm_parameter" "al2023" {
name = "/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64"
}
Things that bite
Resource replacement on immutable attributes. Subnet CIDRs, availability zones, RDS engine versions in some cases. The plan shows -/+ with a # forces replacement annotation. Read every plan for that marker before applying to anything stateful.
Eventual consistency. IAM in particular propagates slowly, so a role created and used in the same apply occasionally fails. Explicit depends_on helps; a retry sometimes is the pragmatic answer.
Provider v4 S3 changes. Versioning, encryption and public access blocks are now separate resources rather than inline blocks. Older tutorials will not work.
Service quotas. Terraform surfaces the API error, which often reads as a permissions problem. Check quotas before assuming IAM.
Explore further in this section
| Sub-topic | What you will learn |
|---|---|
| Terraform AWS VPC | Networking, subnets across AZs, NAT and tagging |
| Terraform AWS EC2 | Instances, AMIs, launch templates and volumes |
| Terraform AWS S3 | Buckets, the split resources, lifecycle and access |
Quick reference
| Task | Syntax |
|---|---|
| Pin the provider | version = "~> 5.0" in required_providers |
| Tag everything | default_tags { tags = { ... } } |
| Assume a role | assume_role { role_arn = "..." } |
| Second region | provider "aws" { alias = "us_east_1" ... } |
| Use an alias | provider = aws.us_east_1 |
| Pass to a module | providers = { aws = aws.us_east_1 } |
| Current AMI | data "aws_ssm_parameter" on /aws/service/ami-... |
| Check identity | aws sts get-caller-identity |
| Available AZs | data "aws_availability_zones" |
Final Thoughts
The Terraform part of AWS work is rarely the hard part. What determines whether an estate stays manageable is the surrounding decisions: pin the provider, tag by default, authenticate with roles rather than keys, keep state separate per environment, and split configurations by how often they change. Get those right early and the resource blocks look after themselves.
Frequently Asked Questions
How should Terraform authenticate to AWS?
Through the standard credential chain, never with keys hardcoded in the provider block. Locally, use a named profile via AWS_PROFILE or AWS SSO. In CI, use OIDC federation. The pipeline exchanges a short-lived signed token for temporary credentials through an IAM role whose trust policy restricts which repository and branch may assume it. That removes long-lived credentials entirely, so there is nothing to rotate and nothing useful in a leaked log.
How do I use Terraform across multiple AWS regions?
Declare additional provider blocks with an alias, then set provider = aws.us_east_1 on resources that belong in the other region. The case everyone meets is ACM certificates for CloudFront, which must be issued in us-east-1 regardless of where the rest of the infrastructure lives. To use an aliased provider inside a module, pass it explicitly through the providers argument. Modules should never declare their own provider configuration.
How do I manage multiple AWS accounts with Terraform?
Use assume_role in the provider block, pointing at a role in the target account, with aliases when one configuration spans several accounts. More commonly, keep each account's infrastructure in its own root configuration with its own backend and credentials, which gives genuine isolation. A pipeline for staging then physically cannot reach production. AWS Organizations plus IAM Identity Center makes the role structure straightforward.
Why does Terraform want to replace my AWS resource instead of updating it?
Because you changed an attribute AWS cannot modify in place: a subnet's CIDR block, an instance's availability zone, certain database engine settings. The plan marks it -/+ and annotates the specific attribute with # forces replacement, so the cause is always stated. On stateless resources this is harmless; on anything storing data it is destructive. Protect critical resources with lifecycle { prevent_destroy = true } so a replacement cannot be applied accidentally.
How do I tag all my AWS resources consistently?
Use default_tags in the provider block, which applies the tags to every resource that supports tagging, with no repetition. Add per-resource tags normally and they merge with the defaults. This is the cheapest way to make cost allocation, ownership and automated cleanup work, and it is considerably easier to set up at the start than to retrofit across an existing estate.
Should the VPC and the application be in the same Terraform configuration?
Usually not. Networking changes rarely and is shared by many workloads; application infrastructure changes constantly. Combining them means every application deploy plans against the whole network, the blast radius of a mistake is larger, and applies are slower. Put the VPC in its own configuration, expose IDs as outputs, and consume them from application configurations through a data source or a value published to SSM Parameter Store.