Organizational

Local AWS Sandbox

Pattern for local development environments that emulate AWS services using LocalStack, including sub-patterns for resource provisioning, Lambda networking, and workflow orchestration.

Production

Pattern for emulating AWS services locally using LocalStack, enabling full Lambda-based development without cloud costs or latency.

Overview

Use this pattern when a repo deploys to AWS Lambda and depends on AWS services (DynamoDB, S3, SSM, API Gateway, etc.) that need to be available during local development and integration testing.

This pattern extends Sandbox Environments. The sandbox remains fully local — all AWS services run in a LocalStack container on the developer's machine.

LocalStack is an emulator, not a substitute for testing against real AWS. Integration tests that validate IAM policies, service limits, or specific AWS behaviors should run against a real AWS environment.

Directory Layout

.sandbox/
├── docker-compose.yml      # LocalStack container definition
├── setup.sh                # Idempotent AWS resource provisioning
├── orchestrator.ts         # Workflow emulator (if repo uses Step Functions)
├── .env.example            # Template for developer-local secrets
├── .env                    # Git-ignored; developer fills in from .env.example
└── .api-url                # Written by setup.sh; consumed by e2e tests

.sandbox/ is committed. .sandbox/.env and .sandbox/.api-url are git-ignored.

Sub-pattern 1: LocalStack Container

The LocalStack container is defined in .sandbox/docker-compose.yml.

Principles:

  • Declare SERVICES explicitly and minimally — only the AWS services the repo actually uses. Avoids startup overhead and prevents unintended service usage from going unnoticed.
  • Pin the LocalStack version tag. Never use latest.
  • Use LAMBDA_EXECUTOR=docker with the Docker socket mount when Lambdas have native dependencies or realistic isolation is needed. This runs each Lambda invocation in a sibling container.
  • Set LAMBDA_DOCKER_NETWORK to the Compose network name (<compose-project-dir>_default by default) so sibling Lambda containers can reach LocalStack.
  • Add host.docker.internal:host-gateway if Lambda handlers call real external APIs (Bedrock, third-party APIs, etc.) — otherwise those calls cannot route out of the container.
services:
  localstack:
    image: localstack/localstack:3.8
    ports:
      - "4566:4566"
    environment:
      - SERVICES=lambda,dynamodb,s3,apigateway,ssm
      - LAMBDA_EXECUTOR=docker
      - LAMBDA_DOCKER_NETWORK=sandbox_default
      - DEBUG=0
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
    extra_hosts:
      - "host.docker.internal:host-gateway"

The Compose project name is load-bearing: LAMBDA_DOCKER_NETWORK=sandbox_default assumes the directory containing docker-compose.yml is named sandbox. If you rename it or pass -p <name> to docker compose, update this value in sync.

Sub-pattern 2: Idempotent Resource Provisioning

setup.sh creates all AWS resources in LocalStack. It is designed to be safely re-run at any time.

Principles:

  • Two-level .env loading: repo root .env (shared defaults) loaded first, .sandbox/.env (developer secrets) loaded second as override using set -a/set +a.
  • awslocal() helper function centralizes --endpoint-url and --region for all aws commands — one place to change if the endpoint moves.
  • Health poll before any resource creation: wait for /_localstack/health to report services ready (30-attempt cap with a clear exit message on failure).
  • Delete-then-create pattern for Lambdas — avoids the two-step update-function-code + update-function-configuration dance.
  • Persist the API Gateway URL to .sandbox/.api-url after deployment. E2e tests read this file rather than querying LocalStack at runtime.
awslocal() {
  aws --endpoint-url="http://localhost:4566" --region "$REGION" "$@"
}

wait_for_localstack() {
  for i in $(seq 1 30); do
    if curl -s http://localhost:4566/_localstack/health | grep -qE '"dynamodb": "(available|running)"'; then
      return 0
    fi
    sleep 1
  done
  echo "LocalStack failed to start" && exit 1
}

Container IP Injection

When using LAMBDA_EXECUTOR=docker, Lambdas run in sibling containers. Inside those containers, localhost:4566 resolves to the Lambda container's own loopback — not LocalStack. The fix is to inject the LocalStack container's bridge-network IP at Lambda-creation time.

get_localstack_ip() {
  docker inspect sandbox-localstack-1 \
    --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' \
    2>/dev/null || echo "localhost"
}

Pass the result as AWS_ENDPOINT_URL=http://<ip>:4566 in the Lambda's environment variables. The AWS SDK v3 reads AWS_ENDPOINT_URL automatically — no code change is needed in Lambda handlers.

The container name sandbox-localstack-1 is the deterministic name Compose assigns (<project>-<service>-<instance>). Substitute accordingly if your Compose project name differs.

AWS Credential Resolution

setup.sh needs real AWS credentials to call Bedrock or other real services that aren't emulated. Resolve them with a cascade:

# 1. Static keys already in environment
# 2. aws configure get aws_access_key_id
# 3. aws configure export-credentials (SSO / role-based profiles)

Exit with a clear, actionable message if credentials cannot be resolved.

Sub-pattern 3: Dual-Credential Plane

The sandbox needs two separate credential planes simultaneously:

PlaneWho uses itCredentials
LocalStackDynamoDB, S3, SSM, Lambda, API Gateway clientsaccessKeyId: 'test', secretAccessKey: 'test'
Real AWSBedrock, external API clientsReal credentials from developer's environment

Separation rules:

  • SDK clients pointing to LocalStack always use test/test hardcoded.
  • Real credentials are resolved in setup.sh and injected into Lambda environments under distinct names (e.g., BEDROCK_ACCESS_KEY_ID, BEDROCK_SECRET_ACCESS_KEY, BEDROCK_SESSION_TOKEN). Lambda code constructs a separate SDK client for those services.
  • Any long-running local process (e.g., the orchestrator) MUST delete ambient AWS environment variables (AWS_PROFILE, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN) at startup to prevent developer credentials from overriding LocalStack's test/test.
// Prevent ambient credentials from leaking into LocalStack clients
delete process.env.AWS_PROFILE;
delete process.env.AWS_ACCESS_KEY_ID;
delete process.env.AWS_SECRET_ACCESS_KEY;
delete process.env.AWS_SESSION_TOKEN;

Sub-pattern 4: Workflow Orchestrator

Applies to: repos that use AWS Step Functions in production and need to execute the full pipeline locally.

In production, Step Functions drives the workflow. Locally, a long-running TypeScript process replaces it: it polls DynamoDB for pending jobs and invokes Lambdas sequentially via the SDK.

Design:

const inProgress = new Set<string>();

async function getPendingJobs(): Promise<string[]> {
  // DynamoDB scan: status === 'PENDING', exclude inProgress
}

async function poll(): Promise<void> {
  const jobs = await getPendingJobs();
  for (const jobId of jobs) {
    inProgress.add(jobId);
    runPipeline(jobId).finally(() => inProgress.delete(jobId));
  }
}

async function loop(): Promise<void> {
  while (true) {
    await poll();
    await new Promise(r => setTimeout(r, POLL_INTERVAL_MS));
  }
}
  • inProgress: Set<string> prevents a job from being picked up again in the next poll cycle while its pipeline is still running.
  • setTimeout (not setInterval) guarantees poll cycles do not overlap even if a pipeline run exceeds POLL_INTERVAL_MS.
  • Use a typed generic invokeLambda<TOut>() function: JSON.stringify → Buffer → InvokeCommand → Buffer → JSON.parse. Check result.FunctionError (not HTTP status) to detect Lambda execution errors.
  • The error path MUST call the repo's handle-error Lambda — same code as production.

Reusable parts: the polling loop, inProgress deduplication, and invokeLambda. Repo-specific: runPipeline() topology and the output interfaces per pipeline step.

Intentional divergences from Step Functions (document these in the orchestrator file header):

  • Executes steps sequentially, not in parallel
  • No automatic retries — failures go directly to handle-error
  • No real state machine ARN; the env var STATE_MACHINE_ARN is a placeholder

Taskfile Tasks

Minimum viable set for a repo using this pattern:

sandbox:start:
  desc: Start complete sandbox (LocalStack + resources + orchestrator)
  deps: [build:lambda]
  cmds:
    - task: sandbox:up        # internal: docker compose up + health wait
    - task: sandbox:setup     # create AWS resources in LocalStack
    - |
      nohup pnpm tsx .sandbox/orchestrator.ts > .sandbox/orchestrator.log 2>&1 &
      echo $! > .sandbox/orchestrator.pid

sandbox:stop:
  desc: Stop sandbox and orchestrator
  cmds:
    - |
      if [ -f .sandbox/orchestrator.pid ]; then
        kill $(cat .sandbox/orchestrator.pid) 2>/dev/null || true
        rm -f .sandbox/orchestrator.pid
      fi
    - docker compose -f .sandbox/docker-compose.yml down

sandbox:setup:
  desc: Create AWS resources in LocalStack
  cmds:
    - bash .sandbox/setup.sh

sandbox:orchestrator:
  desc: Run workflow orchestrator (foreground)
  cmds:
    - pnpm tsx .sandbox/orchestrator.ts

See Sandbox Environments for the full task list (sandbox:restart, sandbox:reset, sandbox:logs, sandbox:status).

dev:run in Serverless Repos

In serverless repos, sandbox:start is the development entry point — there is no application process to start independently of the runtime. dev:run and dev:watch are typically omitted.

See Taskfile Contract for the dev:* namespace guidance.