Local Cloud Testing with LocalStack and Docker: Zero-Cost AWS Mocking in CI/CD

DevOps tutorial - IT technology blog
DevOps tutorial - IT technology blog

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.

Share: