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.
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
SERVICESexplicitly 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=dockerwith 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_NETWORKto the Compose network name (<compose-project-dir>_defaultby default) so sibling Lambda containers can reach LocalStack. - Add
host.docker.internal:host-gatewayif 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
.envloading: repo root.env(shared defaults) loaded first,.sandbox/.env(developer secrets) loaded second as override usingset -a/set +a. awslocal()helper function centralizes--endpoint-urland--regionfor allawscommands — one place to change if the endpoint moves.- Health poll before any resource creation: wait for
/_localstack/healthto 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-configurationdance. - Persist the API Gateway URL to
.sandbox/.api-urlafter 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:
| Plane | Who uses it | Credentials |
|---|---|---|
| LocalStack | DynamoDB, S3, SSM, Lambda, API Gateway clients | accessKeyId: 'test', secretAccessKey: 'test' |
| Real AWS | Bedrock, external API clients | Real credentials from developer's environment |
Separation rules:
- SDK clients pointing to LocalStack always use
test/testhardcoded. - Real credentials are resolved in
setup.shand 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'stest/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(notsetInterval) guarantees poll cycles do not overlap even if a pipeline run exceedsPOLL_INTERVAL_MS.- Use a typed generic
invokeLambda<TOut>()function:JSON.stringify → Buffer → InvokeCommand → Buffer → JSON.parse. Checkresult.FunctionError(not HTTP status) to detect Lambda execution errors. - The error path MUST call the repo's
handle-errorLambda — 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_ARNis 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.
Related Patterns
- Sandbox Environments — base sandbox pattern and principles
- Taskfile Contract — task naming and exposure
- Bedrock Inference Profiles — managing model aliases via SSM
Infrastructure Layout
Pattern for organizing infrastructure-as-code using the .infra/ convention in repositories.
Pattern Authoring
How a pattern in this handbook is written and how the pattern library coheres — the single source of truth for pattern anatomy, classification, cross-linking, and evolution.