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.

#aws#sam#serverless#local-development
Cover image for the article: Local Serverless Development with SAM and Docker: Patterns That Scale

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.

SAM Local Development Architecture

Architecture: The Local Stack

Our local development environment consists of four components:

  1. SAM CLI — Invokes Lambda functions in Docker containers matching AWS runtime images
  2. LocalStack — Emulates DynamoDB, SQS, S3, and EventBridge locally
  3. Docker Compose — Orchestrates LocalStack and any other dependencies
  4. 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:

ApproachFeedback LoopAWS CostFidelity
Deploy to AWS (sam deploy)45-120 sec$$ per deploy100%
SAM local invoke (cold)3-5 sec$085%
SAM local invoke (warm)200-500 ms$085%
Unit tests (mocked AWS)50-200 ms$060%
Integration tests (LocalStack)1-3 sec$090%

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-local Docker 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-local container 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.

Comments

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