Local Serverless Development with SAM and Docker: Patterns That Scale
How to build a productive local development workflow for serverless applications using SAM CLI, Docker, and LocalStack — testing 90% of your logic without deploying.

The Local Development Problem in Serverless
Serverless development has a dirty secret: most teams deploy to AWS to test their code. The feedback loop looks like this: write code → deploy (45-120 seconds) → trigger → check CloudWatch logs → repeat. When you're iterating on a Lambda function that processes SQS messages through a Step Functions workflow, this cycle kills productivity.
SAM CLI with Docker provides a local execution environment that replicates Lambda's runtime, API Gateway routing, and event-source integrations. Combined with LocalStack for DynamoDB and SQS, you can test 90% of your serverless logic locally with sub-second feedback loops.
Architecture: The Local Stack
Our local development environment consists of four components:
- SAM CLI — Invokes Lambda functions in Docker containers matching AWS runtime images
- LocalStack — Emulates DynamoDB, SQS, S3, and EventBridge locally
- Docker Compose — Orchestrates LocalStack and any other dependencies
- Hot-reload watcher — Rebuilds and re-invokes on file changes
# docker-compose.yml — Local AWS emulation
version: '3.8'
services:
localstack:
image: localstack/localstack:3.0
ports:
- '4566:4566' # All services on single port
environment:
- SERVICES=dynamodb,sqs,s3,events
- DEFAULT_REGION=us-east-1
- PERSISTENCE=1
volumes:
- './localstack-data:/var/lib/localstack'
- '/var/run/docker.sock:/var/run/docker.sock'
- './scripts/init-local.sh:/etc/localstack/init/ready.d/init.sh'
The initialization script creates tables and queues matching your production infrastructure:
#!/bin/bash
# scripts/init-local.sh — Create local resources matching production
awslocal dynamodb create-table \
--table-name Orders \
--key-schema AttributeName=PK,KeyType=HASH AttributeName=SK,KeyType=RANGE \
--attribute-definitions \
AttributeName=PK,AttributeType=S \
AttributeName=SK,AttributeType=S \
AttributeName=GSI1PK,AttributeType=S \
AttributeName=GSI1SK,AttributeType=S \
--global-secondary-indexes \
'IndexName=GSI1,KeySchema=[{AttributeName=GSI1PK,KeyType=HASH},{AttributeName=GSI1SK,KeyType=RANGE}],Projection={ProjectionType=ALL}' \
--billing-mode PAY_PER_REQUEST
awslocal sqs create-queue --queue-name order-processing
awslocal sqs create-queue --queue-name order-processing-dlq
awslocal s3 mb s3://order-attachments
SAM Template Configuration for Local Development
Your SAM template needs environment variable overrides for local endpoints:
# template.yaml
Globals:
Function:
Runtime: nodejs20.x
Timeout: 30
MemorySize: 256
Environment:
Variables:
DYNAMODB_ENDPOINT: !If [IsLocal, 'http://host.docker.internal:4566', !Ref 'AWS::NoValue']
SQS_ENDPOINT: !If [IsLocal, 'http://host.docker.internal:4566', !Ref 'AWS::NoValue']
TABLE_NAME: Orders
QUEUE_URL: !If [IsLocal, 'http://host.docker.internal:4566/000000000000/order-processing', !GetAtt OrderQueue.QueueUrl]
Conditions:
IsLocal: !Equals [!Ref Stage, 'local']
Resources:
ProcessOrderFunction:
Type: AWS::Serverless::Function
Properties:
Handler: dist/handlers/process-order.handler
Events:
SQSEvent:
Type: SQS
Properties:
Queue: !GetAtt OrderQueue.Arn
BatchSize: 10
The Client Factory Pattern
To seamlessly switch between local and deployed AWS services, we use a client factory:
// lib/aws-clients.ts
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
import { SQSClient } from '@aws-sdk/client-sqs';
function createDynamoClient(): DynamoDBDocumentClient {
const endpoint = process.env.DYNAMODB_ENDPOINT;
const client = new DynamoDBClient({
...(endpoint && {
endpoint,
region: 'us-east-1',
credentials: { accessKeyId: 'local', secretAccessKey: 'local' },
}),
});
return DynamoDBDocumentClient.from(client);
}
function createSQSClient(): SQSClient {
const endpoint = process.env.SQS_ENDPOINT;
return new SQSClient({
...(endpoint && {
endpoint,
region: 'us-east-1',
credentials: { accessKeyId: 'local', secretAccessKey: 'local' },
}),
});
}
export const dynamodb = createDynamoClient();
export const sqs = createSQSClient();
When DYNAMODB_ENDPOINT is set (local), the client connects to LocalStack. When it's absent (deployed), the client uses the default credential chain and regional endpoint. Same code, zero conditional logic in business functions.
Local Invocation Patterns
SAM CLI supports several invocation modes:
# Invoke a single function with a test event
sam local invoke ProcessOrderFunction \
--event events/sqs-order-created.json \
--docker-network host \
--env-vars env.local.json
# Start a local API Gateway
sam local start-api \
--docker-network host \
--env-vars env.local.json \
--warm-containers EAGER
# Start a local Lambda endpoint for integration tests
sam local start-lambda \
--docker-network host \
--env-vars env.local.json
The --warm-containers EAGER flag keeps containers running between invocations, eliminating the Docker startup overhead on repeated calls. This reduces local invoke time from ~3 seconds to ~200ms.
Benchmarks: Local vs. Deployed Development Loops
We measured developer iteration speed across different testing approaches:
| Approach | Feedback Loop | AWS Cost | Fidelity |
|---|---|---|---|
| Deploy to AWS (sam deploy) | 45-120 sec | $$ per deploy | 100% |
| SAM local invoke (cold) | 3-5 sec | $0 | 85% |
| SAM local invoke (warm) | 200-500 ms | $0 | 85% |
| Unit tests (mocked AWS) | 50-200 ms | $0 | 60% |
| Integration tests (LocalStack) | 1-3 sec | $0 | 90% |
The sweet spot is a combination: fast unit tests for business logic, LocalStack integration tests for AWS service interactions, and SAM local invoke for end-to-end handler testing. Reserve actual deployments for the CI/CD pipeline.
Advanced Pattern: Event Replay from Production
For debugging production issues locally, we capture and replay actual events:
// scripts/capture-events.ts
// Pulls recent events from CloudWatch Logs for local replay
import { CloudWatchLogsClient, FilterLogEventsCommand } from '@aws-sdk/client-cloudwatch-logs';
import { writeFileSync } from 'fs';
async function captureEvents(functionName: string, hours: number = 1) {
const cwl = new CloudWatchLogsClient({});
const startTime = Date.now() - hours * 60 * 60 * 1000;
const response = await cwl.send(new FilterLogEventsCommand({
logGroupName: `/aws/lambda/${functionName}`,
startTime,
filterPattern: '"EVENT_RECEIVED"',
}));
const events = response.events
?.map(e => JSON.parse(e.message!))
.filter(e => e.event)
.map(e => e.event);
writeFileSync(
`events/${functionName}-replay.json`,
JSON.stringify(events, null, 2)
);
console.log(`Captured ${events?.length} events for local replay`);
}
This captures sanitized events from production logs, allowing developers to reproduce exact failure scenarios without guessing at payloads.
Limitations and Workarounds
What SAM local can't do:
- Step Functions execution (use
stepfunctions-localDocker image) - EventBridge rule matching (use LocalStack Pro or integration tests)
- IAM permission testing (always passes locally)
- VPC networking (no NAT gateway emulation)
- Lambda Layers with native binaries (architecture mismatch on Apple Silicon)
Workarounds:
- For Step Functions: use the
stepfunctions-localcontainer with SAM local start-lambda as the endpoint - For IAM: maintain a separate integration test suite that runs in a dedicated AWS account
- For native layers on Apple Silicon: use Docker
--platform linux/amd64(slower but compatible)
Conclusion
A productive serverless development workflow doesn't require deploying to AWS after every change. SAM CLI + Docker + LocalStack gives you sub-second feedback loops for 90% of your development work, with the remaining 10% (IAM, VPC, Step Functions) covered by integration tests in a dedicated AWS account.
The investment pays for itself within the first week: developers make 3-5x more iterations per hour when they're not waiting for deployments. Code quality improves because experimentation is free. And your AWS bill drops because dev accounts aren't running constant deployments.
Start with sam local invoke for your most active Lambda function, add LocalStack for the DynamoDB/SQS interactions, and expand from there. The setup cost is a few hours; the productivity gain is permanent.
Recommended reading

Per-Team Cost Allocation in Shared Kubernetes Clusters: From Chaos to Clarity
Implementing accurate per-namespace cost allocation in multi-tenant Kubernetes clusters, covering request vs. usage attribution, shared resource amortization, and building showback dashboards that drive accountability.

Measuring and Eliminating Toil: From 40% to 12% of Engineering Time
A systematic approach to identifying, measuring, and automating toil—the repetitive operational work that scales linearly with service growth and prevents engineers from doing creative work.

Serverless Postgres in Production: Branching, Scale-to-Zero, and the End of Database Provisioning
Running Neon serverless Postgres in production for 8 months — covering database branching workflows, scale-to-zero economics, connection pooling, and migration from RDS.

Comments
No comments yet. Be the first to share your thoughts.