Infrastructure code deserves tests like application code. With LocalStack you can run terraform apply for real, inside a CI job, in under a minute, with no AWS account and no cost. This post wires that up and shows where it stops being enough.
Versions in use on this date. Terraform 1.10 was released on 27 November 2024, LocalStack 4.0 in November 2024. Terraform’s licence moved to the Business Source License in 2023 (OpenTofu is the community fork); the commands in this post work with either. Pin tool versions in CI.
Pipeline architecture
flowchart TB
PR[Pull request] --> GA[GitHub Actions workflow]
subgraph JOB[Job - validate on LocalStack]
GA --> LSS[Start LocalStack<br/>setup-localstack action]
LSS --> TFI[terraform fmt and validate]
TFI --> TFA[tflocal apply]
TFA --> TST[Integration tests<br/>awslocal checks]
TST --> TFD[tflocal destroy]
end
JOB -->|green| REV[Review and merge]
REV --> MAIN[Main branch]
MAIN --> PLAN[Job - terraform plan on real AWS<br/>OIDC role read only]
PLAN --> GATE[Manual approval environment]
GATE --> APPLY[terraform apply on AWS]
The LocalStack job is a fast pre-merge check that catches syntax errors, missing variables, dependency cycles and wiring mistakes. The real-AWS plan and apply remain a separate, approved stage, because LocalStack cannot confirm IAM permissions, quotas or provider-side validation.
A small module to test
# main.tf
terraform {
required_version = ">= 1.9"
required_providers {
aws = { source = "hashicorp/aws", version = "~> 5.76" }
}
}
provider "aws" { region = "ap-south-1" }
resource "aws_s3_bucket" "uploads" {
bucket = "demo-uploads-ci"
}
resource "aws_s3_bucket_versioning" "uploads" {
bucket = aws_s3_bucket.uploads.id
versioning_configuration { status = "Enabled" }
}
resource "aws_sqs_queue" "events" {
name = "events"
visibility_timeout_seconds = 60
}
resource "aws_sns_topic" "events" { name = "events" }
resource "aws_sns_topic_subscription" "q" {
topic_arn = aws_sns_topic.events.arn
protocol = "sqs"
endpoint = aws_sqs_queue.events.arn
}
output "bucket" { value = aws_s3_bucket.uploads.bucket }
tflocal
tflocal is a thin wrapper that generates provider overrides pointing every AWS service at http://localhost:4566, so the module itself stays unchanged:
pip install terraform-local
docker compose up -d localstack
tflocal init
tflocal plan
tflocal apply -auto-approve
awslocal s3api get-bucket-versioning --bucket demo-uploads-ci
tflocal destroy -auto-approve
It writes a temporary localstack_providers_override.tf (add it to .gitignore). If you prefer not to add a tool, put the endpoint block in a separate local.tfvars-driven provider configuration with skip_credentials_validation, skip_requesting_account_id and s3_use_path_style set.
GitHub Actions workflow
name: infra-ci
on:
pull_request:
paths: ["infra/**"]
permissions:
contents: read
jobs:
localstack:
runs-on: ubuntu-24.04
defaults: { run: { working-directory: infra } }
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with: { terraform_version: 1.10.0 }
- uses: LocalStack/setup-localstack@v0.2.3
with: { image-tag: "4.0" }
- run: pip install terraform-local awscli-local awscli
- run: terraform fmt -check -recursive
- run: tflocal init -input=false
- run: terraform validate
- run: tflocal apply -auto-approve -input=false
- name: Assertions
run: |
awslocal s3api get-bucket-versioning --bucket demo-uploads-ci | grep Enabled
awslocal sqs get-queue-url --queue-name events
- if: always()
run: tflocal destroy -auto-approve -input=false
Pin third-party actions to a released tag, or better to a commit SHA, and check the current versions of setup-localstack and setup-terraform when you adopt this. A pull request from a fork gets no secrets, which is fine here: this job needs none.
Real AWS stage with OIDC
For the deploy job, avoid stored AWS keys. Create an IAM role that trusts GitHub’s OIDC provider, limited to one repository and branch, then:
plan-aws:
needs: localstack
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-24.04
permissions: { id-token: write, contents: read }
environment: production # configure required reviewers for approval
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/gha-terraform
aws-region: ap-south-1
- uses: hashicorp/setup-terraform@v3
- run: terraform -chdir=infra init -input=false
- run: terraform -chdir=infra plan -out=tfplan -input=false
State for the real environment lives in an S3 backend with a lock (a DynamoDB table, or S3-native locking where your Terraform version supports it), never in CI’s workspace.
What this catches, and what it does not
| Caught by LocalStack CI | Needs real AWS |
|---|---|
| Syntax, types, missing variables | IAM permission errors |
| Resource wiring and dependency order | Service quotas and regional availability |
| Module outputs and basic behaviour | Exact API validation rules |
| Destroy and re-create cleanliness | Cost, performance, networking details such as VPC routing |
Troubleshooting
tflocalhangs on S3: use path-style access, whichtflocalconfigures.- Resources are missing in later steps: LocalStack was restarted between steps; keep one job and one container.
- Flaky start: wait for health with
localstack wait -t 60or the health endpoint before applying. - Provider feature unsupported locally: guard it with a variable, so the module can skip it when
use_localstack = true.