LUNAROPS · OPERATIONAL UPLINK 100% UPTIME 1,247d POSTS 893 JEFF.MOON@LUNAROPS.DEV UTC --:--:--

LocalStack: AWS Development Without the Cloud Bill

awslocalstacktestingterraformcdkdevopslambdadockerci-cddevelopment

Waiting on a real AWS environment to run integration tests is a tax on developer velocity. A five-second S3 operation becomes a minute of credential setup, cross-account IAM negotiation, and cleanup scripts that sometimes don’t run. LocalStack replaces the AWS endpoint with a single Docker container, turning us-east-1 into localhost:4566. The pitch is simple; the reality is more nuanced — and in early 2026, the project underwent structural changes significant enough that documentation from a year ago is actively misleading. This post covers how LocalStack works today, what changed, and how to use it without building false confidence in code that will fail in production.


The 2026 Structural Changes You Need to Know First

Before touching any configuration, understand what changed in March 2026:

The community edition was archived. The localstack/localstack open-source repository was archived on March 23, 2026 (the 2026.03.0 release). Both the localstack/localstack and localstack/localstack-pro Docker Hub images now point to the same unified image. What you can access is determined entirely by the entitlements on your auth token.

Auth is now required. Until April 6, 2026, no-auth startup was temporarily possible. That window is closed. A free Hobby account at app.localstack.cloud is required to run anything. Hobby is non-commercial only; commercial use starts at the Base tier.

Calendar versioning replaced semantic versioning. The last semantic release was 4.x. The current release is 2026.05.0. Changelog entries from “LocalStack 3.x” or “LocalStack 4.x” documentation refer to a previous era.

Service tiers:

Tier Price Services Use case
Hobby Free 30+ Personal projects, experimentation, non-commercial CI
Base $39/mo 55+ Commercial dev, adds IAM enforcement + persistence
Ultimate $89/mo 110+ Full service coverage, Cloud Pods, AWS Replicator
Enterprise Custom All Air-gapped, Kubernetes operator, on-prem delivery

The practical impact: if you were using the old community image for free in a commercial product, that is no longer permitted under the terms of service. The old image tags remain on Docker Hub but receive no security patches or feature updates.


Architecture: One Container, One Port

LocalStack runs as a Docker container that presents a single gateway at localhost:4566. This single endpoint routes all AWS service calls — the same path-based and header-based routing used by real AWS. S3, SQS, Lambda, DynamoDB, SNS, IAM, STS, and every other emulated service respond on port 4566.

Some services require running real external processes (OpenSearch, RDS, ElastiCache). These are spawned as child Docker containers in the port range 4510–4559. LocalStack returns the correct endpoint URL from the describe API — you just need to ensure those ports are also mapped.

Your application
       │
       │  http://localhost:4566
       ▼
┌─────────────────────────┐
│     LocalStack Gateway  │  :4566
│  ┌──────┐ ┌──────────┐  │
│  │  S3  │ │ DynamoDB │  │
│  └──────┘ └──────────┘  │
│  ┌──────┐ ┌──────────┐  │
│  │  SQS │ │  Lambda  │──┼──► Docker socket
│  └──────┘ └──────────┘  │        │
│  ┌──────┐               │        ▼
│  │  IAM │               │  Lambda containers
│  └──────┘               │  (per-function, isolated)
└─────────────────────────┘
       │ external services
       ▼
  :4510-4559
  OpenSearch, RDS instances
  (separate Docker containers)

The dummy AWS account ID is always 000000000000. ARNs you create locally will look like arn:aws:s3:::my-bucket and arn:aws:lambda:us-east-1:000000000000:function:my-fn.


Installation

LocalStack now ships a Go binary called lstk alongside the older Python-based localstack CLI:

1
2
3
4
5
6
7
# macOS / Linux via Homebrew
brew install localstack/tap/lstk

# npm
npm install -g @localstack/lstk

# Or download from github.com/localstack/lstk/releases
1
2
3
4
5
6
7
lstk              # start with interactive TUI
lstk stop
lstk status
lstk logs --follow
lstk login        # browser-based OAuth, stores token in system keyring
lstk setup aws    # configure AWS CLI profile automatically
lstk update       # self-update

One gotcha: if you previously ran lstk login, the keyring token takes priority over LOCALSTACK_AUTH_TOKEN in your environment. Run lstk logout first to clear it before switching auth methods.

The localstack CLI (Python, still maintained)

1
2
3
4
5
pip install localstack awscli-local
localstack start          # foreground
localstack start -d       # detached
localstack stop
localstack status services

awscli-local installs the awslocal wrapper, which automatically appends --endpoint-url http://localhost:4566 to every AWS CLI call. Use awslocal everywhere you’d use aws.


Docker Compose Setup

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
services:
  localstack:
    container_name: "${LOCALSTACK_DOCKER_NAME:-localstack-main}"
    image: localstack/localstack
    ports:
      - "127.0.0.1:4566:4566"            # LocalStack Gateway
      - "127.0.0.1:4510-4559:4510-4559"  # External services (OpenSearch, RDS)
    environment:
      - LOCALSTACK_AUTH_TOKEN=${LOCALSTACK_AUTH_TOKEN:?}
      - DEBUG=${DEBUG:-0}
      - PERSISTENCE=${PERSISTENCE:-0}
    volumes:
      - "${LOCALSTACK_VOLUME_DIR:-./volume}:/var/lib/localstack"
      - "/var/run/docker.sock:/var/run/docker.sock"  # required for Lambda
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:4566/_localstack/health"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s

The Docker socket mount is required if you use Lambda — LocalStack spawns separate containers for each function execution using the official AWS Lambda base images.

Key Environment Variables

Variable Default Notes
LOCALSTACK_AUTH_TOKEN Required. Get from app.localstack.cloud
DEBUG 0 Set to 1 for verbose logging
PERSISTENCE 0 Retain state across restarts (Base+)
ENFORCE_IAM 0 Enable IAM policy enforcement (Base+)
IAM_SOFT_MODE 0 Log IAM denials without blocking; use before hard enforcement
SERVICES all Comma-separated restrict list: s3,sqs,lambda
SNAPSHOT_SAVE_STRATEGY SCHEDULED SCHEDULED/ON_REQUEST/ON_SHUTDOWN/MANUAL
LAMBDA_KEEPALIVE_MS 600000 Set to 0 to force cold starts every invocation
DYNAMODB_IN_MEMORY 0 Faster DynamoDB without persistence
OPENSEARCH_ENDPOINT_STRATEGY domain Use port to expose OpenSearch on 4510-4559 range

Do not use DATA_DIR, DEFAULT_REGION, LAMBDA_EXECUTOR, or <SERVICE>_BACKEND — these were removed in recent versions.

Check health and init hook status:

1
2
curl -s http://localhost:4566/_localstack/health | jq .
curl -s http://localhost:4566/_localstack/init/ready | jq .completed

AWS CLI and SDK Configuration

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# Direct with --endpoint-url
aws --endpoint-url http://localhost:4566 s3 mb s3://my-bucket
aws --endpoint-url http://localhost:4566 sqs create-queue --queue-name jobs
aws --endpoint-url http://localhost:4566 lambda list-functions

# With awslocal (preferred — no repetition)
awslocal s3 mb s3://my-bucket
awslocal s3 cp ./file.txt s3://my-bucket/file.txt
awslocal dynamodb create-table \
  --table-name Users \
  --attribute-definitions AttributeName=id,AttributeType=S \
  --key-schema AttributeName=id,KeyType=HASH \
  --billing-mode PAY_PER_REQUEST
awslocal lambda invoke \
  --function-name my-fn \
  --payload '{"key": "value"}' \
  /tmp/output.json
awslocal sqs send-message \
  --queue-url http://sqs.us-east-1.localhost.localstack.cloud:4566/000000000000/my-queue \
  --message-body '{"hello": "world"}'

AWS Profile Configuration

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# ~/.aws/config
[profile localstack]
region = us-east-1
output = json
endpoint_url = http://localhost:4566

# ~/.aws/credentials
[localstack]
aws_access_key_id = test
aws_secret_access_key = test

With endpoint_url in the profile, the AWS CLI v2 routes all calls through LocalStack without --endpoint-url flags. The credentials are dummy values — LocalStack does not validate them unless ENFORCE_IAM=1 is set.


Terraform Integration

Two approaches: manual provider configuration and the tflocal wrapper.

Manual Provider Block

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

provider "aws" {
  access_key                  = "test"
  secret_key                  = "test"
  region                      = "us-east-1"
  s3_use_path_style           = true
  skip_credentials_validation = true
  skip_metadata_api_check     = true
  skip_requesting_account_id  = true

  endpoints {
    dynamodb       = "http://localhost:4566"
    iam            = "http://localhost:4566"
    lambda         = "http://localhost:4566"
    s3             = "http://s3.localhost.localstack.cloud:4566"
    secretsmanager = "http://localhost:4566"
    sns            = "http://localhost:4566"
    sqs            = "http://localhost:4566"
    ssm            = "http://localhost:4566"
    sts            = "http://localhost:4566"
    # Add other services as needed
  }
}

Note the S3 endpoint: http://s3.localhost.localstack.cloud:4566. The domain localhost.localstack.cloud resolves to 127.0.0.1 in DNS — this is required for virtual-hosted-style bucket access. Combined with s3_use_path_style = true, it ensures S3 requests route correctly regardless of bucket name format.

1
2
3
4
5
pip install terraform-local
tflocal init
tflocal plan
tflocal apply
tflocal destroy

tflocal generates a temporary localstack_providers_override.tf pointing all service endpoints to LocalStack. Your existing .tf files need zero modifications. The override file is created per run and ignored by git if you add it to .gitignore.

Practical Example: S3 + DynamoDB + Lambda

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
resource "aws_s3_bucket" "data" {
  bucket = "app-data"
}

resource "aws_dynamodb_table" "state" {
  name         = "app-state"
  billing_mode = "PAY_PER_REQUEST"
  hash_key     = "pk"

  attribute {
    name = "pk"
    type = "S"
  }
}

resource "aws_iam_role" "lambda_role" {
  name = "lambda-exec"
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Action    = "sts:AssumeRole"
      Effect    = "Allow"
      Principal = { Service = "lambda.amazonaws.com" }
    }]
  })
}

resource "aws_lambda_function" "processor" {
  filename         = "function.zip"
  function_name    = "data-processor"
  role             = aws_iam_role.lambda_role.arn
  handler          = "handler.handler"
  runtime          = "python3.12"
  source_code_hash = filebase64sha256("function.zip")

  environment {
    variables = {
      BUCKET_NAME = aws_s3_bucket.data.bucket
      TABLE_NAME  = aws_dynamodb_table.state.name
    }
  }
}

CDK Integration

1
npm install -g aws-cdk-local aws-cdk
1
2
3
4
5
6
7
8
9
# Bootstrap against LocalStack (account 000000000000)
cdklocal bootstrap aws://000000000000/us-east-1

# Synthesize and deploy
cdklocal synth
cdklocal deploy --require-approval never

# Destroy
cdklocal destroy

cdklocal is a thin wrapper that sets AWS_ENDPOINT_URL=http://localhost:4566 and dummy credentials before delegating to cdk. No CDK application code changes are required.

Practical gotchas:

  • CloudFormation stack updates in LocalStack are unreliable. The recommended workflow is cdklocal destroy && cdklocal deploy rather than relying on stack updates. This also matches what happens when an update fails in AWS — you learn about the rollback behavior locally.
  • ARNs in stack outputs will contain 000000000000. If your CDK code constructs cross-stack references that assume real account IDs, they will break.
  • The CDK bootstrap stack (CDKToolkit) is created in LocalStack but its resources are minimal — no real ECR or S3 with versioning, just enough to let the deploy proceed.
  • CloudFormation coverage is substantial but not 1:1. Custom Resources and cfn-signal waiters may not behave identically.

Lambda in LocalStack

Lambda is one of LocalStack’s strongest emulations. Functions run in separate Docker containers using the official AWS Lambda base images from public.ecr.aws/lambda/.

Supported runtimes (current as of 2026.05.0): python3.13, python3.12, python3.11, nodejs22.x, nodejs20.x, java21, java17, dotnet9, dotnet8, go1.x, ruby4.0, provided.al2023. Query the live list:

1
2
awslocal lambda list-runtimes 2>/dev/null || \
  curl -s http://localhost:4566/_aws/lambda/runtimes | jq .

Execution environment differences vs real AWS:

Behavior AWS LocalStack
/tmp persistence Cleared between warm invocations Persists on same warm container
Cold start timing 100ms–10s Typically 1–3s (Docker image pull on first invoke)
Concurrency Full provisioned/reserved Limited; parallel invocations have less isolation
Response streaming Supported Not supported
VPC networking Real ENI injection DNS routing only; no actual network isolation
Function create Synchronous (becomes Active) Async — brief Pending state before Active

The /tmp persistence difference is the most common source of bugs. If your function relies on /tmp being cleared between invocations (a correctness assumption, not a performance one), set LAMBDA_KEEPALIVE_MS=0 to force cold starts.

Hot Reloading

Hot reloading watches your source directory and reloads the Lambda without a redeploy cycle. Detection latency is approximately 700ms.

1
2
3
4
5
6
7
8
awslocal lambda create-function \
  --function-name hot-fn \
  --code S3Bucket="hot-reload",S3Key="/absolute/path/to/your/code/dir" \
  --handler handler.lambda_handler \
  --runtime python3.12 \
  --role arn:aws:iam::000000000000:role/lambda-role

awslocal lambda invoke --function-name hot-fn --payload '{}' /tmp/out.json

Edit your source file. The next invocation picks up the change. Hot reloading requires the hot-reload magic bucket name and an absolute host path in S3Key. It does not work with zip deployments.


State Persistence

All state is ephemeral by default. Container restart = blank slate. This is usually what you want in CI; it is not what you want in local development when you spent 10 minutes seeding a DynamoDB table.

Persistence Mode (Base+)

1
2
3
4
5
environment:
  - PERSISTENCE=1
  - SNAPSHOT_SAVE_STRATEGY=SCHEDULED  # saves modified services every 15s
volumes:
  - "./volume:/var/lib/localstack"
Snapshot Strategy Behavior When to use
SCHEDULED Every 15 seconds for modified services Default; best balance
ON_REQUEST After every mutating API call Maximum durability, higher overhead
ON_SHUTDOWN On container stop No overhead; loses data on crash
MANUAL Never automatically Full control via API

Manual save/load:

1
2
3
curl -X POST http://localhost:4566/_localstack/state/s3/save
curl -X POST http://localhost:4566/_localstack/state/save     # all services
curl -X POST http://localhost:4566/_localstack/state/s3/load

Cloud Pods (Base+)

Cloud Pods are versioned, shareable state snapshots stored in LocalStack’s cloud. They solve the “I need everyone on the team to start from the same seed state” problem:

1
2
3
4
5
6
7
8
9
# Save current LocalStack state
localstack state export my-dev-seed

# Load it into a fresh LocalStack instance
localstack state import my-dev-seed

# Via lstk
lstk state save my-dev-seed
lstk state load my-dev-seed

Storage limits: Base = 300 MB/workspace, Ultimate = 3 GB/workspace, Enterprise = 5 GB/user. Snapshots are not compatible across major version boundaries — the 2026.03.0 release broke compatibility with pods created on older versions.


Initialization Hooks

Scripts mounted into the init directories execute automatically at specific lifecycle stages:

/etc/localstack/init/
├── boot.d/       # before LocalStack runtime starts
├── start.d/      # during startup
├── ready.d/      # when LocalStack is ready to serve requests  ← use this
└── shutdown.d/   # on shutdown

Scripts in ready.d/ run in alphanumerical order. Use numeric prefixes for ordering:

1
2
3
4
5
6
# scripts/00-iam.sh
#!/bin/bash
set -euo pipefail
awslocal iam create-role \
  --role-name lambda-exec \
  --assume-role-policy-document '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"lambda.amazonaws.com"},"Action":"sts:AssumeRole"}]}'
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# scripts/10-storage.sh
#!/bin/bash
set -euo pipefail
awslocal s3 mb s3://uploads
awslocal s3 mb s3://processed
awslocal dynamodb create-table \
  --table-name items \
  --attribute-definitions AttributeName=pk,AttributeType=S AttributeName=sk,AttributeType=S \
  --key-schema AttributeName=pk,KeyType=HASH AttributeName=sk,KeyType=RANGE \
  --billing-mode PAY_PER_REQUEST
1
2
3
4
5
# scripts/20-messaging.sh
#!/bin/bash
set -euo pipefail
awslocal sqs create-queue --queue-name work-queue
awslocal sns create-topic --name notifications

In Docker Compose:

1
2
3
4
5
volumes:
  - "${LOCALSTACK_VOLUME_DIR:-./volume}:/var/lib/localstack"
  - "./scripts/00-iam.sh:/etc/localstack/init/ready.d/00-iam.sh"
  - "./scripts/10-storage.sh:/etc/localstack/init/ready.d/10-storage.sh"
  - "./scripts/20-messaging.sh:/etc/localstack/init/ready.d/20-messaging.sh"

Scripts must be executable (chmod +x). Check completion status:

1
curl -s http://localhost:4566/_localstack/init/ready | jq .completed

CI/CD with GitHub Actions

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
name: Integration Tests
on:
  push:
    branches: [main]
  pull_request:

jobs:
  integration:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Start LocalStack
        uses: LocalStack/setup-localstack@v0.3.2
        with:
          image-tag: 'latest'
          install-awslocal: 'true'
          use-pro: 'true'
          configuration: DEBUG=0,PERSISTENCE=0
        env:
          LOCALSTACK_AUTH_TOKEN: ${{ secrets.LOCALSTACK_AUTH_TOKEN }}

      - name: Seed infrastructure
        run: |
          awslocal s3 mb s3://app-bucket
          awslocal dynamodb create-table \
            --table-name sessions \
            --attribute-definitions AttributeName=id,AttributeType=S \
            --key-schema AttributeName=id,KeyType=HASH \
            --billing-mode PAY_PER_REQUEST
          awslocal sqs create-queue --queue-name jobs

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: '3.12'

      - name: Install dependencies
        run: pip install -r requirements.txt

      - name: Run integration tests
        run: pytest tests/integration/ -v
        env:
          AWS_DEFAULT_REGION: us-east-1
          AWS_ACCESS_KEY_ID: test
          AWS_SECRET_ACCESS_KEY: test
          AWS_ENDPOINT_URL: http://localhost:4566

setup-localstack@v0.3.2 (April 17, 2026) is the current stable version. The LOCALSTACK_AUTH_TOKEN secret needs to be added to your repository settings.

Setting AWS_ENDPOINT_URL in the test environment is the cleanest integration — recent versions of boto3 and the AWS SDK for JavaScript pick it up automatically without per-client configuration.


pytest Integration

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
# conftest.py
import boto3
import pytest

ENDPOINT = "http://localhost:4566"
REGION   = "us-east-1"


def _client(service: str):
    return boto3.client(
        service,
        endpoint_url=ENDPOINT,
        region_name=REGION,
        aws_access_key_id="test",
        aws_secret_access_key="test",
    )


def _resource(service: str):
    return boto3.resource(
        service,
        endpoint_url=ENDPOINT,
        region_name=REGION,
        aws_access_key_id="test",
        aws_secret_access_key="test",
    )


@pytest.fixture(scope="session")
def s3():
    return _client("s3")


@pytest.fixture(scope="session")
def dynamodb():
    return _resource("dynamodb")


@pytest.fixture(scope="session")
def sqs():
    return _client("sqs")


@pytest.fixture(scope="function")
def test_bucket(s3):
    """Fresh bucket per test, cleaned up after."""
    import uuid
    name = f"test-{uuid.uuid4().hex[:8]}"
    s3.create_bucket(Bucket=name)
    yield name
    objects = s3.list_objects_v2(Bucket=name).get("Contents", [])
    for obj in objects:
        s3.delete_object(Bucket=name, Key=obj["Key"])
    s3.delete_bucket(Bucket=name)


@pytest.fixture(scope="function")
def test_table(dynamodb):
    """Fresh DynamoDB table per test, cleaned up after."""
    import uuid
    name = f"test-{uuid.uuid4().hex[:8]}"
    table = dynamodb.create_table(
        TableName=name,
        AttributeDefinitions=[{"AttributeName": "pk", "AttributeType": "S"}],
        KeySchema=[{"AttributeName": "pk", "KeyType": "HASH"}],
        BillingMode="PAY_PER_REQUEST",
    )
    table.wait_until_exists()
    yield table
    table.delete()

If AWS_ENDPOINT_URL is set in the environment, boto3 ≥ 1.28 picks it up automatically. You can simplify fixtures to just boto3.client("s3") in that case — useful for running the same test suite against both LocalStack and real AWS by toggling the env var.


Service-to-Service Testing with Docker Networks

When your application container needs to call LocalStack, localhost from inside the container is the container itself, not the host. Use the container name instead:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
services:
  localstack:
    image: localstack/localstack
    container_name: localstack-main
    networks: [backend]
    environment:
      - LOCALSTACK_AUTH_TOKEN=${LOCALSTACK_AUTH_TOKEN}

  app:
    build: .
    networks: [backend]
    environment:
      - AWS_ENDPOINT_URL=http://localstack-main:4566
      - AWS_DEFAULT_REGION=us-east-1
      - AWS_ACCESS_KEY_ID=test
      - AWS_SECRET_ACCESS_KEY=test
    depends_on:
      localstack:
        condition: service_healthy

networks:
  backend:
    driver: bridge

The condition: service_healthy requires the healthcheck block on the LocalStack service so your app container doesn’t start before LocalStack is ready.


The Fidelity Gaps That Matter

This section is the most important part of this post. LocalStack is not AWS. Code that passes locally can fail in production, and the failure modes follow predictable patterns.

IAM Is Off by Default — This Is Dangerous

By default (ENFORCE_IAM=0), LocalStack accepts every API call regardless of IAM policy. Your Lambda can read any S3 bucket. Your ECS task can describe every EC2 instance. Code that should fail with AccessDeniedException will succeed silently. This is the primary source of “works locally, fails in prod” stories.

Fix: enable ENFORCE_IAM=1 (requires Base tier). Do this with IAM_SOFT_MODE=1 first — it logs denials without blocking, letting you discover what permissions your application actually needs before switching to hard enforcement.

1
2
3
environment:
  - ENFORCE_IAM=1
  - IAM_SOFT_MODE=1  # remove this line once you've audited permissions

Even with enforcement on, some gaps exist:

  • Policy variables in Resource elements (e.g., ${aws:username}) are not evaluated
  • Organizations principal, Federated, and CanonicalUser principal types are unsupported
  • Some cross-service enforcement is missing (API Gateway to S3, Lambda to Kafka)

VPC Is Cosmetic

VPCs, subnets, security groups, and route tables can be created and described via API, but the network topology is not simulated. Security group rules have no effect. Lambda functions placed in a VPC still reach the internet (and still reach LocalStack). NACLs are silently accepted and ignored.

This means any connectivity issue that depends on security group rules, subnet routing, or NACL deny rules will not be caught locally.

Throttling and Service Quotas Don’t Exist

LocalStack never returns ThrottlingException, ServiceQuotaExceededException, or TooManyRequestsException. Code that handles AWS rate limits can’t be tested for correctness against LocalStack — you need a real AWS account with throttled credentials, or a mock layer that injects these errors.

Other Service-Specific Gaps

Cognito: JWT signature, token issuer, and expiration are not validated. An expired Cognito token will succeed locally. The USER_AUTH flow works for basic cases but has edge-case gaps.

Step Functions: Standard workflow execution works. Express Workflows and real service integration error handling diverge from AWS in edge cases. Use LocalStack’s MockedServiceIntegrations feature for deterministic testing of state machines.

API Gateway: REST API (v1) and HTTP API (v2) coverage is good. WebSocket APIs have limited support. Custom domain names don’t function for Hobby/Base tiers. JWT authorizers work against Cognito but not external OIDC.

CloudFormation: Stack creates are reliable. Stack updates are not — use destroy + redeploy. Drift detection is not implemented.

RDS: The API surface is emulated; actual SQL connections go to a real PostgreSQL or MySQL process spawned locally. Aurora-specific features (failover timing, Serverless v2 pause/resume, certain replication flags) behave differently.

S3 presigned URLs: Expiry validation and signature checking differ from AWS. Don’t rely on LocalStack to catch presigned URL permission or expiry bugs.

Eventual consistency: DynamoDB Global Tables, S3 cross-region replication, and similar eventually-consistent constructs behave synchronously in LocalStack. Code that handles replication lag won’t be exercised.


Alternatives After the Community Edition Archival

The March 2026 archival prompted several open-source alternatives aimed at the gap:

Tool Approach Strengths Limitations
Moto Python library, mocks AWS SDK calls via decorators Zero dependencies, fast, excellent for unit tests Python-only, no real HTTP endpoint
AWS SAM Local Lambda + API Gateway emulator only Tight Lambda fidelity, free Single service pair, no S3/DynamoDB/etc.
Testcontainers Real service Docker images per test (DynamoDB Local, ElasticMQ) High per-service fidelity No unified endpoint, wiring overhead
Floci OSS, Quarkus-friendly, MIT licensed Free, growing coverage New project, limited ecosystem maturity
MiniStack OSS, claims 60+ services MIT licensed, Testcontainers modules New project, unproven at scale
LocalStack Unified Docker container, 110+ services Most complete, mature tooling Requires paid auth for commercial use

For pure unit testing of business logic that happens to call AWS SDKs, Moto remains the best free option. For multi-service integration testing where you need S3, SQS, Lambda, and DynamoDB talking to each other, LocalStack’s unified endpoint model has no mature free equivalent post-archival.


Practical Patterns

Tagging for Cleanup

Tag all test resources so a cleanup sweep can find them:

1
2
3
awslocal s3api put-bucket-tagging \
  --bucket my-test-bucket \
  --tagging "TagSet=[{Key=Environment,Value=localstack},{Key=TestRun,Value=${CI_JOB_ID:-local}}]"

Multiple Parallel LocalStack Instances

For parallel CI jobs that can’t share state, vary the gateway port and external service range:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
services:
  localstack-a:
    image: localstack/localstack
    ports:
      - "4566:4566"
      - "4510-4559:4510-4559"
    environment:
      - MAIN_CONTAINER_NAME=localstack-a
      - EXTERNAL_SERVICE_PORTS_START=4510
      - EXTERNAL_SERVICE_PORTS_END=4560

  localstack-b:
    image: localstack/localstack
    ports:
      - "4567:4567"
      - "4560-4609:4560-4609"
    environment:
      - GATEWAY_LISTEN=0.0.0.0:4567
      - MAIN_CONTAINER_NAME=localstack-b
      - EXTERNAL_SERVICE_PORTS_START=4560
      - EXTERNAL_SERVICE_PORTS_END=4610

Getting the OpenSearch Endpoint

With OPENSEARCH_ENDPOINT_STRATEGY=port, the endpoint is on a dynamic port in the external service range. Retrieve it:

1
2
awslocal opensearch describe-domain --domain-name my-domain \
  | jq -r '.DomainStatus.Endpoints.vpc // .DomainStatus.Endpoint'

The returned URL is what your application should use to connect to OpenSearch.


What’s Actually Worth Paying For

IAM Enforcement (Base, $39/mo): The single most important paid feature. Without it, you are testing against a trust-everything system that will give you false confidence. The $39/mo cost is almost certainly less than one production incident caused by a missing IAM policy.

Persistence (Base): Saves hours of re-seeding across restarts. Worth it for any team that iterates heavily on a complex local environment.

Cloud Pods (Base): Share seed state across the team and CI. Eliminates the “works on my machine because I seeded it differently” problem.

AWS Replicator (Ultimate, $89/mo): Replicates real AWS resources (S3 buckets, DynamoDB tables) into your local environment. Useful for developing against production data shapes without a staging environment.

App Inspector (Ultimate): Runtime observability that surfaces misconfigurations and missing IAM permissions in your running application. Essentially a policy advisor that watches your traffic.

The old community edition served as a “good enough” layer that let teams avoid AWS costs entirely. The new Hobby tier serves the same purpose for non-commercial use. For any commercial product, Base is the practical entry point.


Component and Version Reference

Component Version Install
LocalStack for AWS 2026.05.0 (calendar versioning) Docker image
lstk CLI v0.9.0 (May 21, 2026) brew install localstack/tap/lstk
localstack CLI ~4.x (still maintained) pip install localstack
awslocal current pip install awscli-local
cdklocal current npm install -g aws-cdk-local
tflocal current pip install terraform-local
setup-localstack GitHub Action v0.3.2 (April 17, 2026) LocalStack/setup-localstack@v0.3.2
LocalStack Desktop 2.0+ Download from app.localstack.cloud

LocalStack is the most complete AWS emulator available, and the multi-service integration testing use case — where S3, SQS, Lambda, and DynamoDB interact with each other through real HTTP — has no mature free alternative since the community archival. The constraint is the IAM gap: always develop with ENFORCE_IAM=1 on Base or higher, or you are testing a version of your application that will never exist in production.

Comments