Happy Dussehra! May good always win. Happy Diwali! Wishing you a bright year ahead. Merry Christmas! Warm wishes from Vizag. Happy New Year! Here’s to a great year ahead.

Archive · AWS

LocalStack with Terraform and GitHub Actions: Test Infrastructure Before AWS

Use tflocal to apply Terraform against LocalStack, run it in a GitHub Actions job as a pre-merge check, and keep real AWS deployment as a separate gated step.

4 min read

  • LocalStack 4.0
  • tflocal
  • Terraform 1.10
  • GitHub Actions
  • AWS provider

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

  • tflocal hangs on S3: use path-style access, which tflocal configures.
  • 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 60 or the health endpoint before applying.
  • Provider feature unsupported locally: guard it with a variable, so the module can skip it when use_localstack = true.