Context & Why
Six months ago, our staging AWS bill hit $1,400 a month for test infrastructure alone. Every time an engineer opened a pull request touching S3, SQS, or DynamoDB, our GitHub Actions pipeline provisioned ephemeral sandbox resources via Terraform. That approach gave us high-fidelity testing. It also gave us 15-minute wait times and endless frustration.
Pipeline runs crawled. Waiting for remote CloudFormation stacks and AWS API handshakes ate up 8 to 10 minutes per commit. Worse, transient AWS network timeouts caused false-positive failures in roughly one out of every six runs. Engineers started bypassing the suite or batching PRs just to avoid the queue.
We considered building in-house mocks. We dropped that idea quickly. Hand-rolling emulators for intricate AWS signed URLs, message receipt handles, and DynamoDB pagination conditions turns into an operational tar pit.
Instead, we standardized on LocalStack inside Docker. LocalStack runs an AWS-compatible emulator on your machine, exposing services like S3, SQS, SNS, and DynamoDB through a single edge port on 4566. Your application code, AWS SDKs, and CLI tools speak standard AWS protocols—yet zero network packets leave your workstation.
Installation
Running LocalStack requires only Docker and Docker Compose. While you can install LocalStack via Python’s package manager (pip install localstack) or standalone binaries, containerization ensures consistent behavior across Linux, macOS, and build runners.
1. Setting Up Docker Compose
Add a docker-compose.yml file to your test directory or repository root. In modern LocalStack (v2+ and v3+), services load lazily on first request, so you no longer need bloated legacy environment declarations.
services:
localstack:
container_name: localstack-main
image: localstack/localstack:3.2.0
ports:
- "127.0.0.1:4566:4566"
environment:
- DEBUG=0
- DOCKER_HOST=unix:///var/run/docker.sock
volumes:
- "./localstack-data:/var/lib/localstack"
- "/var/run/docker.sock:/var/run/docker.sock"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:4566/_localstack/health"]
interval: 5s
timeout: 3s
retries: 5
2. Starting the Container
Spin up the emulator daemon:
docker compose up -d
Next, install awscli-local. This thin wrapper intercepts standard aws CLI commands and redirects them to http://localhost:4566 with mock credentials pre-filled:
pip install awscli-local
Verify that your mock gateway is healthy by checking the S3 subsystem:
awslocal s3 ls
Configuration
Making a local cloud mock practical takes three things: automatic bootstrap scripts, dynamic SDK endpoint configuration, and lean CI workflow steps.
1. Bootstrapping Resources at Startup
LocalStack executes initialization scripts mounted under /etc/localstack/init/ready.d/ as soon as its internal services pass health checks. Use this hook to create your tables and queues automatically.
Create init-resources.sh:
#!/usr/bin/env bash
set -euo pipefail
echo "Initializing mock cloud resources..."
# Create raw document bucket
awslocal s3 mb s3://incoming-documents
# Create ingestion queue with a 30-second visibility timeout
awslocal sqs create-queue \
--queue-name document-processing-queue \
--attributes VisibilityTimeout=30
# Provision metadata table with on-demand capacity
awslocal dynamodb create-table \
--table-name DocumentMetadata \
--attribute-definitions AttributeName=DocumentId,AttributeType=S \
--key-schema AttributeName=DocumentId,KeyType=HASH \
--billing-mode PAY_PER_REQUEST
echo "Local resources ready."
Mount the script read-only inside your docker-compose.yml:
volumes:
- "./init-resources.sh:/etc/localstack/init/ready.d/init-resources.sh:ro"
- "/var/run/docker.sock:/var/run/docker.sock"
2. Pointing Application Code to Local Endpoints
Production code should never know whether it talks to AWS or LocalStack. Use an AWS_ENDPOINT_URL environment variable to switch targets seamlessly in your factory functions. Here is a battle-tested Python implementation using boto3:
import os
import boto3
# Fall back to None in production so boto3 targets official AWS endpoints
AWS_ENDPOINT_URL = os.getenv("AWS_ENDPOINT_URL")
AWS_REGION = os.getenv("AWS_DEFAULT_REGION", "us-east-1")
def get_s3_client():
return boto3.client(
"s3",
endpoint_url=AWS_ENDPOINT_URL,
region_name=AWS_REGION,
aws_access_key_id=os.getenv("AWS_ACCESS_KEY_ID", "test"),
aws_secret_access_key=os.getenv("AWS_SECRET_ACCESS_KEY", "test"),
)
def upload_invoice(bucket_name: str, file_name: str, content: bytes) -> str:
client = get_s3_client()
client.put_object(
Bucket=bucket_name,
Key=file_name,
Body=content,
ContentType="application/pdf"
)
return f"s3://{bucket_name}/{file_name}"
3. Automating in GitHub Actions
Running LocalStack as a job service container avoids cloud permission setups, IAM roles, and secret rotation in pull requests. LocalStack boots directly on the runner host:
name: Integration Tests
on:
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
services:
localstack:
image: localstack/localstack:3.2.0
ports:
- 4566:4566
options: >-
--health-cmd "curl -f http://localhost:4566/_localstack/health || exit 1"
--health-interval 5s
--health-timeout 3s
--health-retries 10
steps:
- uses: actions/checkout@v4
- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
python-version: '3.11'
cache: 'pip'
- name: Install Test Dependencies
run: |
python -m pip install --upgrade pip
pip install pytest boto3 awscli-local
- name: Initialize Mock Infrastructure
run: |
chmod +x ./init-resources.sh
./init-resources.sh
- name: Run Test Suite
env:
AWS_ENDPOINT_URL: http://localhost:4566
AWS_DEFAULT_REGION: us-east-1
AWS_ACCESS_KEY_ID: test
AWS_SECRET_ACCESS_KEY: test
run: |
pytest tests/integration/ -v --maxfail=1
Verification & Monitoring
Fast feedback is only useful if assertions are trustworthy and failures are simple to inspect.
1. Endpoint Health Diagnostics
LocalStack serves live diagnostics via /_localstack/health. Querying this endpoint returns the status of every active subsystem:
curl -s http://localhost:4566/_localstack/health | jq .
A healthy instance reports services ready on demand:
{
"services": {
"dynamodb": "running",
"s3": "running",
"sqs": "running"
},
"edition": "community",
"version": "3.2.0"
}
If any service reports disabled or error, fail your build before running test suites.
2. Validating State Changes
Confirm state mutations between test assertions directly via the CLI. For instance, verify that an upload landed in S3 or check queue depth after triggering an event worker:
# Verify uploaded invoice
awslocal s3 ls s3://incoming-documents/
# Check pending messages in the processing queue
awslocal sqs get-queue-attributes \
--queue-url http://localhost:4566/000000000000/document-processing-queue \
--attribute-names ApproximateNumberOfMessages
3. Diagnosing Test Failures
When an assertion fails with a 403, 404, or 500 error, inspect the container’s standard output. LocalStack prints every intercepted HTTP request with its target action and body payload:
docker logs localstack-main --tail 50
The payoff was immediate. Our PR test runtime dropped from 14 minutes down to 2 minutes 35 seconds. Staging cloud spend fell by $1,400 per month, and false-positive pipeline breaks dropped to zero. Developers get deterministic, sub-second test iterations right from their laptops without touching live cloud infrastructure.

