Terraform for AWS: Setup, Auth, State & CI/CD
Terraform is the most widely used Infrastructure as Code tool in the AWS ecosystem. You describe AWS resources in plain text files, preview exactly what will change before anything is touched, and apply those changes in a repeatable, auditable way. This guide takes you from a blank directory to a deployed AWS resource and covers the decisions you will face along the way: authentication, state, workflow, and CI/CD.
What Terraform is in plain English
Imagine you manage ten AWS resources by clicking through the console. They work, but nobody wrote down exactly how they were configured. Three months later, you need a second environment. You click through the console again, hoping you remember every setting. You probably do not.
Terraform is a blueprint system for AWS. Instead of building resources by hand each time, you write a blueprint (HCL files) describing exactly what you want. Terraform reads the blueprint, checks what already exists, and only changes what is different. Anyone with the blueprint can build the same thing. The blueprint lives in git, gets reviewed in pull requests, and can recreate your entire environment on demand.
That is Infrastructure as Code in practice. Terraform is the tool that makes it work for AWS.
Who this page is for
This page is for developers and engineers who are new to Terraform or just starting to use it with AWS. You will leave knowing how to install Terraform, connect it to AWS securely, write a working configuration, and understand how teams use it safely in CI/CD pipelines.
What Terraform does in AWS
Terraform manages AWS resources through five core concepts.
HCL (HashiCorp Configuration Language) is the language you write configs in. It reads like a simplified mix of JSON and a settings file. Each block describes one thing: a resource, a variable, or a provider setting.
Providers are plugins that translate your HCL into actual AWS API calls. The AWS provider handles authentication and knows how to create every AWS service. You declare the provider, pin its version, and Terraform downloads it automatically on init.
State is a JSON file that records what Terraform has created. Every time you run apply, Terraform updates this file. On the next plan, it compares state against your config and against real AWS. The difference is what it proposes to change.
Plans are Terraform’s preview step. Before touching anything, Terraform calculates the exact set of creates, updates, and deletes needed to bring your infrastructure in line with your config. You review the plan, approve it, then apply.
Resource lifecycle is how Terraform handles the full lifespan of a resource: create on first apply, update in place when possible, destroy-and-recreate when a change requires it (like renaming an S3 bucket), and delete on destroy.
How Terraform works: the mental model
Terraform’s workflow is always the same six steps. Once this sequence clicks, the rest of Terraform makes sense.
- Write config — describe desired infrastructure in
.tffiles. - init — download the AWS provider plugin and any referenced modules.
- plan — compare config against current state and real AWS. Show a diff of what would change.
- apply — execute the plan. Terraform makes the API calls to create, update, or delete resources.
- State tracking — Terraform writes the new state after every successful apply.
- Update or destroy — edit the config and run plan/apply again, or run
terraform destroyto tear everything down.
The loop is simple: write, init, plan, apply, repeat. The state file is what makes this safe. Terraform never has to guess what exists in your AWS account.
Install Terraform and connect it to AWS
Install Terraform
Terraform is a single binary. Install it through the official HashiCorp repository or with tfenv, a version manager that lets you switch Terraform versions per project.
# macOS with Homebrew
brew tap hashicorp/tap
brew install hashicorp/tap/terraform
# Ubuntu / Debian
wget -O- https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
sudo apt update && sudo apt install terraform
# Verify
terraform versionPin the version in every project using required_version (shown in the config below). Different Terraform versions can produce different plans, which creates confusion in code review.
Connect Terraform to AWS
The AWS provider needs credentials to make API calls. Use this priority order:
1. IAM role: the right choice for automation. In a CI/CD pipeline, attach an IAM role to the runner or use OIDC federation. Terraform picks up the role automatically with no access keys to store, rotate, or leak. This is also the right choice when running Terraform on an EC2 instance.
2. AWS named profile: the right choice for local development. Configure a named profile and set AWS_PROFILE. Credentials stay out of your Terraform files entirely.
aws configure --profile myproject
export AWS_PROFILE=myproject3. Environment variables: fine for local work. Acceptable in some CI systems that do not support OIDC.
export AWS_ACCESS_KEY_ID="AKIA..."
export AWS_SECRET_ACCESS_KEY="..."
export AWS_REGION="us-east-1"Never hard-code credentials in Terraform files. Access keys in provider blocks end up in git history and are routinely leaked. Use IAM roles, profiles, or environment variables in that order of preference. Read more about why long-lived access keys are dangerous.
Your first AWS Terraform project
A minimal project has two files: versions.tf pins the tools, and main.tf describes the resources.
# versions.tf
terraform {
required_version = ">= 1.7.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = "us-east-1"
}# main.tf
resource "aws_s3_bucket" "example" {
bucket = "my-unique-bucket-name-2026"
tags = {
Environment = "dev"
ManagedBy = "terraform"
}
}
resource "aws_s3_bucket_versioning" "example" {
bucket = aws_s3_bucket.example.id
versioning_configuration {
status = "Enabled"
}
}The versioning resource references aws_s3_bucket.example.id. This is how Terraform resolves dependencies: it knows the bucket must exist before the versioning configuration, so it creates them in the correct order automatically. Learn more about what S3 buckets are and how S3 versioning works if these concepts are new.
The core workflow: init, plan, apply, destroy
# Step 1: Initialize the project
# Downloads the AWS provider plugin, sets up .terraform directory
terraform init
# Step 2: Preview what will change
# Shows resources to be created (+), modified (~), or destroyed (-)
terraform plan
# Step 3: Apply the changes
# Terraform asks for confirmation before making any changes
terraform apply
# Step 4: Tear down resources when done
terraform destroyHow to read plan output
The plan is the most important step. Know what each symbol means before you apply:
+create: new resource being added~update in-place: existing resource being modified without recreation-destroy: resource will be deleted-/+destroy and recreate: Terraform must delete the old resource and create a new one
Watch for -/+ on databases. A destroy-and-recreate on an RDS instance means your database will be deleted and replaced with an empty one. Always read the full plan before applying, not just the summary count at the bottom.
Variables, outputs, and provider blocks
Hard-coding values in resource blocks makes configs fragile and non-reusable. Variables let you parameterize your config; outputs expose values after apply.
# variables.tf
variable "environment" {
description = "Deployment environment (dev, staging, prod)"
type = string
default = "dev"
}
variable "bucket_name" {
description = "Name of the S3 bucket"
type = string
# No default forces Terraform to require this value at runtime
}# outputs.tf
output "bucket_arn" {
description = "ARN of the created S3 bucket"
value = aws_s3_bucket.example.arn
}# Pass values at runtime
terraform apply -var="bucket_name=my-project-bucket" -var="environment=prod"
# Or use a tfvars file
terraform apply -var-file="prod.tfvars"The ~> 5.0 version constraint means “accept 5.x but not 6.0”. This allows minor version updates while blocking major-version breaking changes. Always pin provider versions.
To deploy into multiple AWS regions in one config, declare a second provider block with an alias:
provider "aws" {
alias = "eu"
region = "eu-west-1"
}
resource "aws_s3_bucket" "eu_bucket" {
provider = aws.eu
bucket = "my-eu-bucket"
}When to use Terraform on AWS
Terraform is a strong fit when infrastructure needs to be repeatable, reviewable, or shared across a team.
- Multi-environment setups where the same config runs in dev, staging, and prod with different variables
- Team workflows where infrastructure changes are reviewed in pull requests like application code
- Cross-service provisioning: create an EC2 instance, its security group, IAM role, and S3 bucket together
- Audit trails: git history shows every infrastructure change, author, and reason
- Multi-cloud: manage AWS alongside GCP or Azure from a single toolchain
When Terraform is not the best fit
Terraform has real overhead. For some use cases, that overhead is not worth it:
- One-off manual experiments: if you are exploring a service and will tear it down in an hour, the console is faster
- Serverless-only workloads on AWS: AWS SAM or the Serverless Framework handle Lambda and API Gateway deployments with less ceremony
- Teams that prefer AWS-native tooling: CloudFormation and CDK are both valid; if your team already knows them well, there is no reason to switch
- Simple single-service deployments: deploying a static site to S3 or a single Lambda is often easier with the AWS CLI or console
Terraform vs CloudFormation vs CDK
The Infrastructure as Code in AWS overview covers all IaC options in depth. Here is the practical comparison for the Terraform decision:
| Tool | Language | State management | Scope | Best for |
|---|---|---|---|---|
| Terraform | HCL | External (S3 + DynamoDB) | Multi-cloud | Multi-cloud teams, large module ecosystem, strong community tooling |
| CloudFormation | YAML / JSON | Managed by AWS | AWS-only | AWS-only setups, zero extra tooling, supports every AWS service on day one |
| CDK | TypeScript, Python, Go, Java | Managed by AWS (via CloudFormation) | AWS-only | Teams who want loops, conditionals, and type-checked infrastructure in a real language |
How to choose
Think of it like choosing a build tool. Terraform is like a cross-platform build tool: it works everywhere, the ecosystem is huge, but you manage its cache (state) yourself. CloudFormation is like the built-in build system that ships with your OS: less flexible, but AWS maintains everything for you. CDK is like writing your build config in JavaScript instead of XML: same underlying system, more expressive language.
Choose Terraform if your team uses multiple cloud providers, you want access to the Terraform Registry’s module library, or your team already has HCL experience. Expect to manage state in S3 with DynamoDB locking.
Choose CloudFormation if you are AWS-only and want the simplest operational setup. No external tooling, no state file management, and AWS handles rollbacks natively.
Choose CDK if your team writes TypeScript or Python and wants infrastructure that feels like library code. CDK compiles to CloudFormation, so you get AWS-native state management and full service support.
Honest recommendation for new AWS-only teams: start with Terraform if your team has Terraform experience or wants multi-cloud flexibility. Start with CloudFormation or CDK to minimize external tooling. All three are production-grade. Pick the one your team will actually maintain.
Working safely in teams: remote state and project structure
When you run terraform apply locally, Terraform writes a terraform.tfstate file in your project directory. This works fine when you are the only person running Terraform. It breaks the moment anyone else does the same.
Why local state breaks in teams: two people applying at the same time each read the last-known state and make conflicting changes. The state file gets corrupted. Resources become invisible to Terraform. Recovery is painful and sometimes means manually reconciling hundreds of resources.
The standard solution is an S3 remote backend with DynamoDB state locking. S3 stores the state file so all team members read the same version. DynamoDB provides a distributed lock so only one apply can run at a time.
# backend.tf
terraform {
backend "s3" {
bucket = "my-project-terraform-state"
key = "environments/prod/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "terraform-state-lock"
encrypt = true
}
}As your project grows, file structure also starts to matter. A flat main.tf becomes unmanageable past a few dozen resources. See Terraform Project Structure for AWS Teams for how to organize files and environments. For a full walkthrough of remote backends, state locking, and state migration, see Terraform State Management in AWS.
Terraform in CI/CD with OIDC
The common mistake in CI/CD is storing AWS access keys as pipeline secrets. They need rotation, they appear in logs if not masked, and they are long-lived. A leaked key stays valid until manually revoked.
The right approach is OIDC (OpenID Connect) federation. GitHub Actions, GitLab CI, and most modern CI/CD platforms support it. The pipeline exchanges a short-lived OIDC token for a temporary IAM role assumption via AWS STS. No keys are stored anywhere and the token expires after the job ends.
# GitHub Actions — OIDC auth for Terraform
permissions:
id-token: write
contents: read
steps:
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/GitHubActionsRole
aws-region: us-east-1
- name: Terraform plan
run: |
terraform init
terraform plan -out=tfplan
- name: Terraform apply
if: github.ref == 'refs/heads/main'
run: terraform apply tfplanThe IAM role’s trust policy restricts which GitHub repository can assume it, so other repos cannot use the same role accidentally. For full details on keyless AWS deployments, see GitHub Actions for AWS Deployments and Secure CI/CD Pipelines in AWS.
For managing the IAM roles that Terraform itself needs, see AWS IAM with Terraform: Roles, Policies, and Examples. If Terraform throws access denied errors in CI/CD, the Terraform Permission Errors troubleshooting guide covers the most common causes.
Common mistakes
- Hard-coding credentials in provider blocks. Access keys in Terraform files end up in git history. Use IAM roles, AWS profiles, or environment variables.
- Skipping the plan step. Running
terraform applywithout reviewing the plan is how production databases get accidentally deleted. Always run plan first and read the full output. - Using local state in a team. The
terraform.tfstatefile in your project directory cannot be safely shared. Two concurrent applies will corrupt it. Move to S3 and DynamoDB before any team workflow begins. - Not pinning provider or Terraform versions. An unpinned AWS provider can auto-upgrade and introduce unexpected plan changes. Pin with
~> 5.0and upgrade deliberately. - Making manual changes in the AWS console. Once a resource is managed by Terraform, all changes must go through Terraform. Console changes are invisible to Terraform’s state and will be reverted on the next apply.
- Treating Terraform like a shell script. Terraform is stateful. Deleting a resource block from your config does not just stop managing that resource. It schedules the real AWS resource for deletion on the next apply.
- Committing tfvars files that contain secrets. Add
*.tfvarsto.gitignoreand store sensitive values in AWS Secrets Manager or Parameter Store instead.
Summary
- Terraform describes AWS resources in HCL files and manages them through a consistent init, plan, apply, and destroy workflow.
- Authentication priority: IAM role for automation, AWS profile for local work, environment variables as a fallback. Never hard-code credentials.
- The plan output is the most important step.
+creates,~updates,-/+destroy-and-recreate. Always read it before applying. - Pin provider and Terraform versions in every project using
required_versionandrequired_providers. - Local state is fine for solo learning. Switch to S3 and DynamoDB remote state before working in a team or CI/CD pipeline.
- In CI/CD, use OIDC role assumption for short-lived credentials with no long-lived access keys stored anywhere.
- Terraform is a strong fit for multi-environment, team-based, multi-cloud infrastructure. CloudFormation or CDK are valid alternatives for AWS-only setups.
Frequently asked questions
How does Terraform authenticate with AWS?
Terraform uses the AWS provider, which reads credentials in this order: environment variables (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY), the shared credentials file (~/.aws/credentials), or an IAM role attached to the machine. In CI/CD pipelines, OIDC-based role assumption is the right approach with no long-lived credentials stored anywhere. Never hard-code access keys in provider blocks.
Should I use Terraform or CloudFormation for AWS?
For AWS-only projects with no multi-cloud plans, CloudFormation is a solid choice with zero extra tooling. Choose Terraform if your team needs multi-cloud support, wants access to the Terraform Registry module ecosystem, or already knows HCL. CDK is a strong option for teams who prefer TypeScript or Python. The comparison table on this page breaks down the tradeoffs.
When do I need remote state?
The moment more than one person applies Terraform changes, or when your CI/CD pipeline runs Terraform. Local state files cannot be safely shared. Two people applying at the same time will corrupt the state. Move to an S3 and DynamoDB backend before any team workflow begins.
Can Terraform manage existing AWS resources that I created manually?
Yes, using terraform import. You provide the resource type, the Terraform resource name you want to assign, and the AWS resource ID. Terraform adds the resource to its state file without destroying and recreating it. You still need to write the matching configuration manually since import only updates state, not config files.
Do I need modules when starting out?
No. A flat main.tf is the right starting point. Modules add abstraction and complexity. Extract one when you find yourself copy-pasting the same resource block for a second environment. The Terraform Project Structure guide covers when and how to modularize.