Arrange Act Assert

Jag Reehal's thoughts on things, mostly product development

What can you test locally with AWS Emulator?

16 Nov 2026

AWS Emulator serves the AWS APIs from a container on your machine, with no AWS account. We moved our CDK ephemeral environments onto it and tested CloudFormation, SQS, SNS, EventBridge and three kinds of Lambda against it.

An open laptop with a glass dome on its keyboard, covering a small orange cloud wired to server racks, a database and storage blocks

tl;dr: AWS Emulator provisioned our CDK stack in 12 seconds with two custom resources turned off, including the NAT gateway, RDS and load balancer wiring our previous emulator could not create. Most of those resources are records: the ECS containers did not start and the NAT gateway routes nothing. The RDS instance answered psql with a Postgres-compatible engine. SQS, SNS and EventBridge returned the errors AWS returns and delivered messages when a scoped queue policy allowed it. Lambda event functions ran our code. Durable and web functions deployed but did not execute. The JavaScript SQS client needs one setting, or it sends requests to real AWS.

The cost of testing against AWS

You write code that calls AWS. To test it today you pick one of two costs. You deploy to a real account and wait for CloudFormation, pay for a NAT gateway, and remember to tear it down. Or you run a third-party emulator and hope its authors guessed the same validation rules AWS enforces.

AWS Emulator shrinks that trade-off. AWS builds it, checks its responses against real AWS, and publishes the fidelity checks on GitHub. You start it, point the AWS CLI or an SDK at it with environment variables, and run most of your code as it stands. We hit two exceptions, the JavaScript SQS client and Lambda bundling, and cover both below. The emulator keeps the resources you create in memory, so a ListBuckets after a CreateBucket returns your bucket. When a call breaks a rule, you get the error message AWS would send.

AWS describes it as “closest to AWS for control plane operations”: the calls that create, describe, update and delete resources. Fidelity varies from service to service beyond that, and our results show it.

Our setup

We keep a CDK app for ephemeral environments in jagreehal/aws-cdk-ephemeral-environments. Every pull request deploys its own stack: a VPC, an ECS Fargate service behind an Application Load Balancer, an RDS database, an S3 bucket, KMS keys, alarms and a dashboard. CI destroys the stack when the pull request closes. I wrote about why that matters in Give AI somewhere to prove its work.

We also wanted a local deploy for anyone without AWS credentials. Until this week, make local-deploy ran the stack against MiniStack. MiniStack’s CloudFormation engine rejected five parts of our template, so we kept a flag, isLocal, that stripped them out:

Trimmed for MiniStack Missing resource type
NAT gateway and Elastic IP AWS::EC2::NatGateway, AWS::EC2::EIP
VPC flow logs AWS::EC2::FlowLog
RDS instance and its secret AWS::RDS::DBSubnetGroup
Load balancer to service wiring AWS::EC2::SecurityGroupIngress
S3 auto-delete, default security group lockdown Lambda-backed custom resources

The local stack and the real stack drifted apart, and every workaround left a comment in our constructs explaining itself. The feat/aws-sim branch replaces MiniStack with AWS Emulator.

Run the emulator in Docker

On Linux you install AWS Emulator from an RPM or Debian package. On macOS and Windows you run it in Docker, and AWS publishes a Dockerfile in place of an image. We picked the package architecture at build time with uname -m, so the same file works on Apple Silicon and Intel:

FROM public.ecr.aws/amazonlinux/amazonlinux:2023-minimal

RUN curl -fsSL -o /tmp/aws-emulator.rpm \
      "https://downloads.aws.com/aws-emulator/packages/aws-emulator/LTS/latest/aws-emulator-$(uname -m).rpm" \
    && rpm -i /tmp/aws-emulator.rpm && rm /tmp/aws-emulator.rpm \
    && mkdir -p /home/emulator && chown 1000 /home/emulator

USER 1000
ENV HOME=/home/emulator

EXPOSE 9297
ENTRYPOINT ["aws-emulator", "-b", "0.0.0.0"]

We would pin the version if we could. AWS serves the package from a latest URL, the download ignores S3 version IDs, its GitHub releases carry fidelity scores and no packages, and it shipped five releases on the day we tested. Every result in this post comes from version 1.0.385. Check yours with docker compose exec aws-emulator aws-emulator --version, and expect some of our gaps to have closed.

On start, the emulator generates credentials, an AWS config file and a CA certificate, and writes them to $HOME/.aws/emulator/<port>/. Our Compose file mounts that folder at the same path inside and outside the container, so the AWS CLI and SDKs on your machine read the files the emulator wrote:

services:
  aws-emulator:
    build: ./emulator
    user: '${EMULATOR_UID:-1000}:${EMULATOR_GID:-1000}'
    ports:
      - '127.0.0.1:9297:9297'
    environment:
      HOME: ${HOME}
    volumes:
      - ${HOME}/.aws/emulator:${HOME}/.aws/emulator
    command: ['-p', '9297', '--telemetry', 'off']
    healthcheck:
      test: ['CMD', 'curl', '-fsk', 'https://127.0.0.1:9297/__admin/health']
      interval: 5s
      timeout: 3s
      retries: 12

The user line runs the emulator as you. On Linux, AWS recommends --user "$(id -u):$(id -g)" so the credentials file belongs to your user and not to UID 1000. Our Makefile exports EMULATOR_UID and EMULATOR_GID from id, and the same setting works on macOS.

Bind the port to 127.0.0.1 as above. A bare 9297:9297 opens the emulator to your network. The emulator sends anonymous usage data unless you pass --telemetry off. It requires SigV4-signed requests by default, using the keys it generated, and it serves HTTPS with its own CA.

Point your tools at it

Start the emulator, then set five variables in your shell and clear four others:

mkdir -p ~/.aws/emulator
EMULATOR_UID=$(id -u) EMULATOR_GID=$(id -g) docker compose up -d --build --wait

export AWS_ENDPOINT_URL=https://127.0.0.1:9297
export AWS_REGION=us-east-1
export AWS_CONFIG_FILE=$HOME/.aws/emulator/9297/config
export AWS_SHARED_CREDENTIALS_FILE=$HOME/.aws/emulator/9297/emulator_credentials
export NODE_EXTRA_CA_CERTS=$HOME/.aws/emulator/9297/ca.pem
unset AWS_PROFILE AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN

The unset line matters. Leave a real profile or access key in your shell and it wins over the emulator’s credentials.

aws sts get-caller-identity --query Account --output text
# 000000000000

Warning: the JavaScript SQS client can reach real AWS

AWS_ENDPOINT_URL does not keep every request on your machine. Our first Node script created a queue on the emulator and sent its message to AWS:

import { SQSClient, CreateQueueCommand, SendMessageCommand } from '@aws-sdk/client-sqs';

const sqs = new SQSClient({});

const { QueueUrl } = await sqs.send(new CreateQueueCommand({ QueueName: 'sdk-demo' }));
await sqs.send(new SendMessageCommand({ QueueUrl, MessageBody: '{"orderId":"1003"}' }));
QueueUrl=https://sqs.us-east-1.amazonaws.com/000000000000/sdk-demo differs from
SQSClient resolved endpoint=https://127.0.0.1:9297/, using QueueUrl host as endpoint.

InvalidClientTokenId: The security token included in the request is invalid.

The emulator returns queue URLs with the real SQS hostname, as AWS does. The v3 SQS client sends each request to the host in the queue URL by default. CreateQueue went to the emulator, SendMessage went to AWS, and AWS rejected the emulator’s keys. With real credentials in your shell, that request reaches your real account. The same applies to integration tests in CI and to any AI coding agent you point at the emulator.

Set useQueueUrlAsEndpoint: false when you build the client for the emulator:

const sqs = new SQSClient({ useQueueUrlAsEndpoint: false });
[ '{"orderId":"1003"}' ]

The AWS CLI uses the configured endpoint and did not have this problem.

Deploy the CDK stack

The CDK toolkit uses the AWS SDK for JavaScript v3, which reads AWS_ENDPOINT_URL. We dropped the cdklocal wrapper and its dependency, and the Makefile now runs plain cdk with the variables above:

make local-deploy

Our first attempt deployed the full production template with no trimming. CloudFormation on the emulator created the NAT gateway, Elastic IP, flow logs, RDS subnet group, database, secret and every standalone security group rule. Two resources failed: CDK’s Lambda-backed custom resources for S3 auto-delete and default security group lockdown. The emulator created the Lambda functions, but the custom resources never completed.

We deleted every other workaround. isLocal now turns off those two custom resources and nothing else. The emulator provisioned the rest, NAT gateway and database included, in 12.21 seconds:

local-ephemeral
  ClusterName: local-ephemeral-ecs
  LoadBalancerUrl: http://127.0.0.1:35603
  DatabaseSecretArn: arn:aws:secretsmanager:us-east-1:000000000000:secret:/local-ephemeral/rds/credentials-EBhugg
  StorageBucketName: local-ephemeral-securitystoragebucket49526882-16bf9fe9c60d

make local-destroy removed it in a few seconds more. The diff came to 113 lines added and 189 removed.

An emulated resource is not a working service, and each resource in our stack landed somewhere different on that line. We asked each one what it could do:

Resource What the emulator gave us
ECS service A service record with 1 task desired and 0 running. No container starts, and the load balancer answers 503.
NAT gateway A record in the available state with an emulated public IP, 54.0.0.2. Nothing runs in the VPC to route through it.
RDS for PostgreSQL An endpoint on 127.0.0.1 that speaks the Postgres wire protocol.

The database surprised us. We fetched the generated credentials from Secrets Manager and connected with psql from a container on the emulator’s network:

docker run --rm --network container:$(docker compose ps -q aws-emulator) \
  -e PGPASSWORD="$PASSWORD" postgres:16-alpine \
  psql "host=127.0.0.1 port=$PORT user=app_user dbname=postgres" \
  -c 'create table orders(id int primary key, total numeric);' \
  -c 'insert into orders values (1001, 42.5) returning *;'

It created the table and returned the row. It rejected a wrong password with password authentication failed, rejected a duplicate key with Postgres’s duplicate key value violates unique constraint "orders_pkey", and handled jsonb operators and a recursive CTE. It is an engine inside the emulator, not PostgreSQL: there is no Postgres process in the container, version() returns a bare PostgreSQL 16.4, and gen_random_uuid() fails with a syntax error. Run your migrations against it to catch mistakes before you deploy, and run them against real RDS before you trust them.

MiniStack started a real Docker container for each ECS task, so you could hit the app. With AWS Emulator, use the deploy to prove your stack provisions, updates and destroys, and test the running app another way.

Try it: SQS

The emulator returns account 000000000000, so the ARNs below use it.

Create a queue, send a message and read it back:

QUEUE_URL=$(aws sqs create-queue --queue-name orders --query QueueUrl --output text)
aws sqs send-message --queue-url "$QUEUE_URL" --message-body '{"orderId":"1001"}'
aws sqs receive-message --queue-url "$QUEUE_URL" --query 'Messages[].Body' --output text
# {"orderId":"1001"}

FIFO queues deduplicate. Send the same body twice with content-based deduplication on, and you get the same message ID back and one message on the queue:

FIFO_URL=$(aws sqs create-queue --queue-name orders.fifo \
  --attributes FifoQueue=true,ContentBasedDeduplication=true \
  --query QueueUrl --output text)

aws sqs send-message --queue-url "$FIFO_URL" --message-body '{"orderId":"1001"}' \
  --message-group-id customer-42 --query MessageId --output text
# 80c07e90-eb1c-4224-8a0f-5b65260161dd
aws sqs send-message --queue-url "$FIFO_URL" --message-body '{"orderId":"1001"}' \
  --message-group-id customer-42 --query MessageId --output text
# 80c07e90-eb1c-4224-8a0f-5b65260161dd

aws sqs get-queue-attributes --queue-url "$FIFO_URL" \
  --attribute-names ApproximateNumberOfMessages \
  --query Attributes.ApproximateNumberOfMessages --output text
# 1

Forget the message group ID and the emulator rejects the call as AWS does:

aws sqs send-message --queue-url "$FIFO_URL" --message-body '{"orderId":"1002"}'
# An error occurred (MissingParameter) when calling the SendMessage operation:
# The request must contain the parameter MessageGroupId.

Dead-letter queues work too. Give a queue a redrive policy with maxReceiveCount of 2 and a one-second visibility timeout, then fail to process a message:

aws sqs create-queue --queue-name payments-dlq > /dev/null
PAYMENTS_URL=$(aws sqs create-queue --queue-name payments \
  --attributes '{"VisibilityTimeout":"1","RedrivePolicy":"{\"deadLetterTargetArn\":\"arn:aws:sqs:us-east-1:000000000000:payments-dlq\",\"maxReceiveCount\":\"2\"}"}' \
  --query QueueUrl --output text)

aws sqs send-message --queue-url "$PAYMENTS_URL" --message-body poison > /dev/null
for attempt in 1 2 3; do
  echo "attempt $attempt: $(aws sqs receive-message --queue-url "$PAYMENTS_URL" --query 'Messages[0].Body' --output text)"
  sleep 2
done
# attempt 1: poison
# attempt 2: poison
# attempt 3: None

DLQ_URL=$(aws sqs get-queue-url --queue-name payments-dlq --query QueueUrl --output text)
aws sqs receive-message --queue-url "$DLQ_URL" --query 'Messages[0].Body' --output text
# poison

You can test your poison-message handling on a laptop in under ten seconds.

Try it: SNS fan-out

flowchart LR
  P[aws sns publish] --> T[order-events topic]
  T --> Q1[order-fanout queue]
  T -- "total >= 100" --> Q2[big-orders queue]

Create a topic, a queue, and subscribe one to the other:

TOPIC_ARN=$(aws sns create-topic --name order-events --query TopicArn --output text)
FANOUT_URL=$(aws sqs create-queue --queue-name order-fanout --query QueueUrl --output text)

aws sns subscribe --topic-arn "$TOPIC_ARN" --protocol sqs \
  --notification-endpoint arn:aws:sqs:us-east-1:000000000000:order-fanout \
  --attributes RawMessageDelivery=true

aws sns publish --topic-arn "$TOPIC_ARN" --message '{"orderId":"1001","total":42.5}'
aws sqs receive-message --queue-url "$FANOUT_URL" --wait-time-seconds 3
# (nothing)

The queue stays empty. We first read that as a gap in the emulator. Then we remembered that AWS behaves the same way: SNS delivers to a queue when the queue policy lets sns.amazonaws.com call sqs:SendMessage. Add a policy, and scope it to this topic with aws:SourceArn as AWS recommends:

cat > allow-sns.json <<'EOF'
{
  "Policy": "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"Service\":\"sns.amazonaws.com\"},\"Action\":\"sqs:SendMessage\",\"Resource\":\"arn:aws:sqs:us-east-1:000000000000:order-fanout\",\"Condition\":{\"ArnEquals\":{\"aws:SourceArn\":\"arn:aws:sns:us-east-1:000000000000:order-events\"}}}]}"
}
EOF
aws sqs set-queue-attributes --queue-url "$FANOUT_URL" --attributes file://allow-sns.json

aws sns publish --topic-arn "$TOPIC_ARN" --message '{"orderId":"1001","total":42.5}'
aws sqs receive-message --queue-url "$FANOUT_URL" --wait-time-seconds 3 --query 'Messages[].Body' --output text
# {"orderId":"1001","total":42.5}

The emulator enforced the condition. We subscribed the same queue to a second topic, other-events, and published to it. Nothing arrived, because the policy names order-events as the only allowed source. CDK’s topic.addSubscription writes a scoped policy for you. If you write CloudFormation by hand and get the topic or principal wrong, the emulator shows you the empty queue before AWS does.

Subscription filter policies work on the message body. This subscriber wants orders of 100 or more:

BIG_URL=$(aws sqs create-queue --queue-name big-orders --query QueueUrl --output text)
sed 's/order-fanout/big-orders/' allow-sns.json > allow-sns-big.json
aws sqs set-queue-attributes --queue-url "$BIG_URL" --attributes file://allow-sns-big.json

aws sns subscribe --topic-arn "$TOPIC_ARN" --protocol sqs \
  --notification-endpoint arn:aws:sqs:us-east-1:000000000000:big-orders \
  --attributes '{"RawMessageDelivery":"true","FilterPolicyScope":"MessageBody","FilterPolicy":"{\"total\":[{\"numeric\":[\">=\",100]}]}"}'

aws sns publish --topic-arn "$TOPIC_ARN" --message '{"orderId":"1002","total":20}'
aws sns publish --topic-arn "$TOPIC_ARN" --message '{"orderId":"1003","total":500}'

aws sqs receive-message --queue-url "$BIG_URL" --wait-time-seconds 3 \
  --max-number-of-messages 10 --query 'Messages[].Body' --output text
# {"orderId":"1003","total":500}

Drop RawMessageDelivery and you receive the full SNS envelope, with Type, TopicArn, Timestamp and a signature. Your consumer’s parsing code gets the same shape it sees in production.

Try it: EventBridge rules

Route high-value orders from the default event bus to a queue. EventBridge needs the same kind of queue policy, with events.amazonaws.com as the principal and the rule as the source:

EVENTS_URL=$(aws sqs create-queue --queue-name high-value-orders --query QueueUrl --output text)

aws events put-rule --name high-value-orders \
  --event-pattern '{"source":["shop.orders"],"detail-type":["OrderPlaced"],"detail":{"total":[{"numeric":[">",100]}]}}'

cat > allow-events.json <<'EOF'
{
  "Policy": "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"Service\":\"events.amazonaws.com\"},\"Action\":\"sqs:SendMessage\",\"Resource\":\"arn:aws:sqs:us-east-1:000000000000:high-value-orders\",\"Condition\":{\"ArnEquals\":{\"aws:SourceArn\":\"arn:aws:events:us-east-1:000000000000:rule/high-value-orders\"}}}]}"
}
EOF
aws sqs set-queue-attributes --queue-url "$EVENTS_URL" --attributes file://allow-events.json

aws events put-targets --rule high-value-orders \
  --targets Id=queue,Arn=arn:aws:sqs:us-east-1:000000000000:high-value-orders

Put two events, one above the threshold and one below:

aws events put-events --entries \
  'Source=shop.orders,DetailType=OrderPlaced,Detail="{\"orderId\":\"1004\",\"total\":250}"' \
  'Source=shop.orders,DetailType=OrderPlaced,Detail="{\"orderId\":\"1005\",\"total\":20}"'

aws sqs receive-message --queue-url "$EVENTS_URL" --wait-time-seconds 3 \
  --max-number-of-messages 10 --query 'Messages[].Body' --output text
# {"version":"0","id":"18e80bbd-...","detail-type":"OrderPlaced","source":"shop.orders",
#  "account":"000000000000","time":"2026-11-14T23:02:41Z","region":"us-east-1",
#  "resources":[],"detail":{"orderId":"1004","total":250}}

Order 1004 arrives in the full EventBridge envelope. Order 1005 never does. A second rule that targeted the same queue delivered nothing, because the policy names high-value-orders as the only allowed source.

TestEventPattern lets you unit test a pattern without a rule or a target:

aws events test-event-pattern \
  --event-pattern '{"detail":{"total":[{"numeric":[">",100]}]}}' \
  --event '{"id":"1","account":"000000000000","source":"shop.orders","time":"2026-11-14T00:00:00Z","region":"us-east-1","resources":[],"detail-type":"OrderPlaced","detail":{"total":99}}'
# { "Result": false }

Write "source" as a string in place of an array and the emulator rejects the rule with AWS’s message:

aws events put-rule --name broken --event-pattern '{"source":"shop.orders"}'
# An error occurred (InvalidEventPatternException) when calling the PutRule operation:
# Event pattern is not valid. Reason: "source" must be an object or an array

DynamoDB validates the same way. AWS’s own example, a table named ab, fails with Member must have length greater than or equal to 3.

Try it: Lambda

Lambda now comes in three shapes: event functions, durable functions and web functions. The emulator gave us three different results.

Event functions run your code

Write a handler, zip it and invoke it:

cat > index.mjs <<'EOF'
export const handler = async (event) => ({ hello: event.name ?? 'world' });
EOF
zip -q fn.zip index.mjs

aws lambda create-function --function-name greet --runtime nodejs22.x \
  --handler index.handler --role arn:aws:iam::000000000000:role/lambda-role \
  --zip-file fileb://fn.zip > /dev/null
aws lambda wait function-active-v2 --function-name greet

aws lambda invoke --function-name greet --cli-binary-format raw-in-base64-out \
  --payload '{"name":"jag"}' out.json > /dev/null && cat out.json
# {"hello":"jag"}

Your console.log output lands in CloudWatch Logs under /aws/lambda/greet, and --log-type Tail returns it on the invoke. In our runs a made-up role ARN worked. Do not rely on that: AWS documents that some services in the emulator evaluate IAM policies for users and roles you create there.

The emulator image contains no Node.js binary. It runs your JavaScript in its own engine, which reports process.version as v22.0.0. That engine does not resolve packages from a node_modules folder in your zip, so bundle with esbuild or similar before you upload:

npx esbuild index.mjs --bundle --platform=node --format=cjs --target=node22 --outfile=dist/index.js

Durable functions deploy but do not execute

Lambda durable functions checkpoint each step, suspend on a wait and resume where they left off. We wrote a checkout workflow: reserve stock, wait five seconds, ask a manager to approve through a callback, then charge the card if they said yes.

The control plane works. The emulator accepts --durable-config, validates it as AWS does, and starts an execution when you invoke a published version:

aws lambda create-function --function-name checkout --runtime nodejs22.x \
  --handler index.handler --role arn:aws:iam::000000000000:role/lambda-role \
  --zip-file fileb://bundle.zip --timeout 30 \
  --durable-config ExecutionTimeout=900,RetentionPeriodInDays=1 > /dev/null
aws lambda wait function-active-v2 --function-name checkout
VERSION=$(aws lambda publish-version --function-name checkout --query Version --output text)

aws lambda invoke --function-name checkout --qualifier "$VERSION" \
  --invocation-type Event --cli-binary-format raw-in-base64-out \
  --payload '{"orderId":"1001"}' /dev/null --query DurableExecutionArn --output text
# arn:aws:lambda:us-east-1:000000000000:function:checkout:1/durable-execution/f5097.../e7826...

get-durable-execution, list-durable-executions-by-function and stop-durable-execution all answer. ExecutionTimeout=0 fails with valid min value: 1.

The workflow never runs. The durable SDK imports node:async_hooks, and the emulator’s JavaScript engine does not provide it:

{"errorMessage":"Error: Cannot find module 'node:async_hooks'", "errorType":"Runtime.ImportModuleError"}

The execution history stops at ExecutionStarted. An execution you start with --invocation-type Event stays RUNNING after the handler fails, until you stop it. The callback API answers NotImplemented: “operation SendDurableExecutionCallbackSuccess is scaffolded but not implemented in this emulator”.

AWS publishes a test runner for durable functions that runs the workflow in-process, with fake time. I cover it, and the approval logic a payment workflow needs, in Test Lambda durable functions without deploying.

Web functions get an endpoint that returns 501

Lambda Web Functions run a Node.js HTTP server, Express or Hono or plain node:http, behind an HTTPS endpoint. The AWS CLI ships a lambda-web deploy command that scaffolds a starter app, uploads it to S3, creates the IAM role, function, revision and endpoint, and waits for the endpoint to go active. It works against the emulator:

aws lambda-web deploy --name hello --hello-world --create
# Creating starter application in ./hello ...
# Creating deployment package ...
# Preparing Amazon S3 bucket ...
# Preparing AWS IAM execution role ...
# Uploading deployment package ...
# Creating Lambda Web Function ...
# Waiting for endpoint to become active (state Pending, 0s elapsed, timeout 60s) ...
# PerRegion endpoint URLs (1): us-east-1 -> https://127.0.0.1:42815

Run it from an empty folder, since it writes the starter app to ./hello. The function and its first revision show up as Active in list-web-functions and list-web-function-revisions.

The endpoint listens inside the container, on a port the emulator picks, so call it from there:

docker compose exec aws-emulator curl -sk -i https://127.0.0.1:42815/
# HTTP/1.1 501 Not Implemented
#
# This web function endpoint is not implemented: the emulator runs no web function
# code, so the handler's own response cannot be served.

A second lambda-web deploy fails, because the command calls ListWebFunctionEndpoints and the emulator scaffolds that operation without implementing it. To test the server, run npm start on your laptop. AWS designed web functions so the same server runs there unchanged.

Know the limits

AWS lists these in the user guide. We hit the first one hardest:

Where each test runs

Put the findings together and you get a layered testing strategy. Each layer gives you feedback faster than the one below it, and you deploy to AWS for the questions a laptop cannot answer:

Test Where to run it
CDK infrastructure provisioning AWS Emulator
Event routing and message delivery AWS Emulator
A first pass of database migrations AWS Emulator’s RDS endpoint, then real RDS
Durable workflow logic AWS’s durable test runner
Application and HTTP behaviour Your app image in Docker Compose, or npm start
IAM and complete system integration An ephemeral AWS environment per pull request

We considered keeping MiniStack or adding LocalStack for the gaps, and chose neither. MiniStack ran our ECS container, and a Compose service does the same job with one less layer. We have not tested LocalStack against this stack, so we make no claim about it.

The same layers suit AI coding agents. An agent that can deploy to the emulator, run the durable tests and start the app checks its own work in seconds, and saves the ephemeral environment for the final proof.

Clean up

make local-destroy   # remove the CDK stack
make local-down      # stop the emulator and drop all state

Conclusion

We replaced a third-party emulator, a wrapper CLI and five workarounds with a Dockerfile, a Compose file and five environment variables. Our local stack now matches the stack we deploy to AWS, minus two custom resources.

We still deploy an ephemeral environment for every pull request. We reach for it later in the loop now: after the emulator has shown that the stack provisions, the events route and the queues deliver, and after the durable test runner has walked each branch of the workflow. Real AWS tests what a laptop cannot, which is IAM, the running app and the system as a whole.

For more on testing distributed systems, read Designing supportable event-driven architectures and Ready to deploy, or ready to support?.

Clone aws-cdk-ephemeral-environments, check out feat/aws-sim and run make local-deploy. To go further, read the AWS Emulator user guide and the aws-emulator repository on GitHub.

awstestingarchitecturetypescript