Code that talks to AWS is awkward to run locally. Pointing it at a real account means credentials on every laptop, a shared bucket that someone else just emptied, and a bill for the test that looped overnight. Mocking the SDK avoids all of that, and also avoids testing the part that actually breaks: the bucket policy, the queue wiring, the event that never arrives.
LocalStack sits in between. It is a single container that answers the AWS APIs on one port, so the AWS CLI and the official SDKs work against it unchanged. This guide builds a small Compose project around it with S3 and SQS, the pair behind a very common pattern: a file lands in a bucket, and a worker picks it up from a queue. Every file is shown in full, and all of it was tested against LocalStack 4.14.0 and AWS SDK for JavaScript v3.
By the end you will have:
- LocalStack running in Docker, with a healthcheck that only passes once your resources exist.
- An init script that creates a bucket, a queue and the notification between them on every start.
- The AWS CLI that ships inside the LocalStack image, so you can inspect everything without installing it.
- A Node.js uploader and worker, built into their own image, that run unchanged against AWS, with two settings that make them work locally.
Prerequisites
- Docker Desktop, or Docker Engine with the Compose plugin.
- About 2 GB of disk for the LocalStack and Node.js images.
That is all. Node.js and the AWS CLI run inside containers, so neither needs to be installed on your machine, and no AWS account is needed. Create an empty folder for the project and work from inside it:
mkdir localstack-demo && cd localstack-demoThe project layout
localstack-demo/
├── docker-compose.yml
├── init/
│ └── ready.d/
│ └── 01-resources.sh
├── files/
└── app/
├── Dockerfile
├── package.json
├── aws.mjs
├── upload.mjs
└── worker.mjsTwo services run from it:
- localstack emulates S3 and SQS on port 4566 and runs the init script once both are ready.
- worker is built from
app/and waits for upload events on the queue. It starts only after LocalStack reports healthy. The same image runs the uploader.
The empty files/ folder is where you put the files to upload. You will start LocalStack on its own first and explore it with the AWS CLI, then bring in the worker.
1. The Compose file
name: localstack-demo
services:
localstack:
image: localstack/localstack:4.14.0
environment:
- SERVICES=s3,sqs
ports:
- 127.0.0.1:4566:4566
volumes:
- ./init/ready.d:/etc/localstack/init/ready.d:ro
healthcheck:
# Healthy once the init scripts have run, and only if none of them failed
test: ["CMD-SHELL", "curl -fs localhost:4566/_localstack/init/ready > /tmp/init.json && grep -q '\"completed\": true' /tmp/init.json && ! grep -q '\"ERROR\"' /tmp/init.json"]
interval: 5s
timeout: 5s
retries: 12
worker:
build: ./app
restart: on-failure
depends_on:
localstack:
condition: service_healthy
volumes:
- ./files:/files:ro
environment:
- AWS_ENDPOINT_URL=http://localstack:4566
- AWS_REGION=us-east-1
- AWS_ACCESS_KEY_ID=test
- AWS_SECRET_ACCESS_KEY=testThe image tag
The tag is pinned to 4.14.0 on purpose. From the CalVer releases (2026.04 onwards), the localstack/localstack image is LocalStack's commercial build, and its tags, latest included, refuse to start without an auth token tied to a LocalStack account:
Reason: No credentials were found in the environment. Please set the LOCALSTACK_AUTH_TOKEN variable to a valid auth token.4.14.0 is the last release of the open source community edition, and it starts without an account. It is frozen, so it will not get new features or fixes. If you would rather track current releases, create an account, add LOCALSTACK_AUTH_TOKEN=${LOCALSTACK_AUTH_TOKEN} to the environment and change the tag. Nothing else in this guide changes.
Either way, pin a version. An emulator that changes under you on a random docker pull produces the exact kind of "works on my machine" failure that LocalStack is supposed to remove.
Services, port and init scripts
Every AWS service LocalStack emulates answers on the same port, 4566, and the request itself says which service it is for. SERVICES=s3,sqs loads only those two, which keeps startup at around ten seconds. Anything else answers with an error, as the pitfalls section shows.
As in any local stack, the port is published on 127.0.0.1 only. LocalStack accepts any credentials, so anyone who can reach the port can read and delete everything in it.
LocalStack runs every executable script in /etc/localstack/init/ready.d once all services are up. Mounting a folder there is how the bucket and queue get created without anyone typing a command.
The healthcheck
/_localstack/init/ready reports on the init scripts:
{"completed": true, "scripts": [{"stage": "READY", "name": "01-resources.sh", "state": "SUCCESSFUL"}]}The obvious healthcheck waits for "completed": true, and it has a hole: a script that fails also counts as completed, with "state": "ERROR". LocalStack would report healthy with no bucket, and the worker would crash on its first call. The check above passes only when the scripts have finished and none of them failed, so a broken script makes docker compose up --wait fail loudly instead:
dependency failed to start: container localstack-demo-localstack-1 is unhealthyThe worker
The worker gets the same four environment variables you would set for any AWS SDK. The only local detail is the endpoint, http://localstack:4566, which uses the Compose service name. Inside a container, localhost is the container itself. restart: on-failure brings the worker back if it loses LocalStack, for example after LocalStack restarts.
The image is built from app/, so npm install runs inside Docker and never on your machine. ./files is mounted read-only at /files, which is how the uploader reads the files you drop there.
2. The init script
#!/usr/bin/env bash
# Runs inside the LocalStack container once every service is ready.
set -euo pipefail
awslocal s3 mb s3://uploads
queue_url=$(awslocal sqs create-queue --queue-name uploads-events --query QueueUrl --output text)
queue_arn=$(awslocal sqs get-queue-attributes --queue-url "$queue_url" \
--attribute-names QueueArn --query Attributes.QueueArn --output text)
# Every new object in the bucket sends a message to the queue
awslocal s3api put-bucket-notification-configuration --bucket uploads \
--notification-configuration "{
\"QueueConfigurations\": [{ \"QueueArn\": \"${queue_arn}\", \"Events\": [\"s3:ObjectCreated:*\"] }]
}"
echo "LocalStack resources ready"awslocal ships inside the image. It is the AWS CLI with the endpoint and test credentials already filled in. The script must be executable. Otherwise LocalStack fails to run it with Permission denied, and the healthcheck keeps the container unhealthy:
chmod +x init/ready.d/01-resources.shOn real AWS, a queue also needs an access policy that allows the bucket to send to it. LocalStack does not enforce IAM by default, so the script leaves it out. That is convenient, and it is also the first thing to remember when the same setup moves to a real account.
3. Start LocalStack
Create the files/ folder yourself, so that it belongs to you rather than to Docker, and start LocalStack:
mkdir -p files
docker compose up -d --wait localstackNaming the service starts only LocalStack. The first run downloads the image. After that, it is up in about ten seconds:
docker compose ps -a --format 'table {{.Service}}\t{{.Status}}'SERVICE STATUS
localstack Up 11 seconds (healthy)docker compose logs localstack shows each call the init script made, followed by LocalStack resources ready.
4. Talk to LocalStack with the AWS CLI
awslocal is already inside the LocalStack container, so docker compose exec is enough to use it:
docker compose exec localstack awslocal s3 ls2026-10-03 00:16:12 uploadsEvery command you know from AWS works the same way. Upload a file and look at the queue. The file comes from standard input, and -T lets docker compose exec read it:
echo 'hello' | docker compose exec -T localstack awslocal s3 cp - s3://uploads/reports/report.txt
docker compose exec localstack awslocal sqs receive-message \
--queue-url http://localhost:4566/000000000000/uploads-events \
--max-number-of-messages 10 --query 'Messages[].Body'000000000000 is the account ID LocalStack uses by default. The queue holds two messages. One is the event for report.txt. The other is an s3:TestEvent that S3 sends once, when the notification is configured, and real AWS does the same:
[
"{\"Service\": \"Amazon S3\", \"Event\": \"s3:TestEvent\", \"Bucket\": \"uploads\", ...}",
"{\"Records\": [{\"eventName\": \"ObjectCreated:Put\", ... \"object\": {\"key\": \"reports/report.txt\", \"size\": 6, ...}}}]}"
]Receiving a message does not delete it. It is hidden for 30 seconds, the queue's default visibility timeout, and then becomes visible again, so the worker will see both messages too.
5. Use it from your code
The app runs in its own image, built from two files. package.json lists the two AWS SDK clients, pinned to the version this guide was tested with:
{
"name": "uploads-app",
"private": true,
"type": "module",
"dependencies": {
"@aws-sdk/client-s3": "^3.1146.0",
"@aws-sdk/client-sqs": "^3.1146.0"
}
}The Dockerfile installs them and copies the scripts in. In a real project, commit the package-lock.json that npm install generates and use npm ci, so every build gets exactly the same versions:
FROM node:22-alpine
WORKDIR /app
COPY package.json ./
RUN npm install --omit=dev
COPY *.mjs ./
CMD ["node", "worker.mjs"]The goal is application code that has no idea LocalStack exists. The AWS SDKs read AWS_ENDPOINT_URL from the environment just like the region and the credentials, so that part needs no code at all. Two settings still differ, and they live in one module:
import { S3Client } from '@aws-sdk/client-s3'
import { SQSClient } from '@aws-sdk/client-sqs'
// The SDK reads AWS_ENDPOINT_URL, AWS_REGION and the credentials from the environment.
// Only two settings change when that endpoint points at LocalStack.
const local = Boolean(process.env.AWS_ENDPOINT_URL)
// Path-style URLs (localstack:4566/uploads/key) instead of uploads.localstack:4566/key
export const s3 = new S3Client({ forcePathStyle: local })
// Send every call to AWS_ENDPOINT_URL, not to the host inside the queue URL
export const sqs = new SQSClient({ useQueueUrlAsEndpoint: !local })Both settings fix a hostname problem, and the pitfalls section shows what happens without them. In production AWS_ENDPOINT_URL is unset, and both clients behave exactly as they always have.
The uploader puts a file in the bucket:
import { readFile } from 'node:fs/promises'
import { basename } from 'node:path'
import { PutObjectCommand } from '@aws-sdk/client-s3'
import { s3 } from './aws.mjs'
const file = process.argv[2]
const key = `reports/${basename(file)}`
await s3.send(new PutObjectCommand({ Bucket: 'uploads', Key: key, Body: await readFile(file) }))
console.log(`Uploaded s3://uploads/${key}`)The worker long-polls the queue, reads each new object and deletes the message once it is handled:
import { GetObjectCommand } from '@aws-sdk/client-s3'
import { DeleteMessageCommand, GetQueueUrlCommand, ReceiveMessageCommand } from '@aws-sdk/client-sqs'
import { s3, sqs } from './aws.mjs'
const { QueueUrl } = await sqs.send(new GetQueueUrlCommand({ QueueName: 'uploads-events' }))
console.log(`Waiting for uploads on ${QueueUrl}`)
while (true) {
const { Messages = [] } = await sqs.send(
new ReceiveMessageCommand({ QueueUrl, MaxNumberOfMessages: 10, WaitTimeSeconds: 20 }),
)
for (const message of Messages) {
const event = JSON.parse(message.Body)
// S3 sends one s3:TestEvent when the notification is configured; it has no Records
for (const record of event.Records ?? []) {
const bucket = record.s3.bucket.name
const key = decodeURIComponent(record.s3.object.key.replace(/\+/g, ' '))
const object = await s3.send(new GetObjectCommand({ Bucket: bucket, Key: key }))
const body = await object.Body.transformToString()
console.log(`${key}: ${body.split('\n').filter(Boolean).length} lines`)
}
await sqs.send(new DeleteMessageCommand({ QueueUrl, ReceiptHandle: message.ReceiptHandle }))
}
}Object keys arrive URL-encoded in S3 events, with spaces as +, which is why the key is decoded before it is used.
Start the worker. The first time, Compose builds its image. Then put a file in files/ and run the uploader in a one-off container from the same image, with the same environment:
docker compose up -d --wait worker
printf 'region,revenue\nnorth,1200\nsouth,950\n' > files/q3.csv
docker compose run --rm worker node upload.mjs /files/q3.csv
docker compose logs workerUploaded s3://uploads/reports/q3.csv
worker-1 | Waiting for uploads on http://sqs.us-east-1.localhost.localstack.cloud:4566/000000000000/uploads-events
worker-1 | reports/q3.csv: 3 lines
worker-1 | reports/report.txt: 1 linesreport.txt comes from step 4. Its line can come first or last: if the receive-message call was less than 30 seconds ago, its message is still hidden, and the worker picks it up as soon as the visibility timeout runs out.
The file went from a folder on your machine into S3, S3 published an event to SQS, and a worker in another container consumed it. That is the whole round trip, with no AWS account involved.
Pitfalls worth knowing in advance
Hostnames
S3 wants the bucket in the hostname. By default, the SDK addresses a bucket as uploads.<endpoint>, which turns localstack:4566 into uploads.localstack:4566. Nothing resolves that name, and the upload fails after three retries:
Error: getaddrinfo ENOTFOUND uploads.localstackforcePathStyle puts the bucket in the path instead, which works with any endpoint. LocalStack also supports bucket hostnames through its public DNS name, s3.localhost.localstack.cloud, but every subdomain of it resolves to 127.0.0.1, which inside a container is the container itself.
SQS queue URLs carry their own host. LocalStack returns queue URLs such as http://sqs.us-east-1.localhost.localstack.cloud:4566/.... Recent versions of the JavaScript SDK send requests to the host in the queue URL rather than to the configured endpoint. That name resolves to 127.0.0.1, which only works by luck when the code runs directly on your machine. Inside the worker container it is the worker itself, and the first call that uses the queue URL fails:
QueueUrl=http://sqs.us-east-1.localhost.localstack.cloud:4566/000000000000/uploads-events differs from SQSClient resolved endpoint=http://localstack:4566/, using QueueUrl host as endpoint.
Set [endpoint=string] or [useQueueUrlAsEndpoint=false] on the SQSClient.
Error: connect ECONNREFUSED 127.0.0.1:4566useQueueUrlAsEndpoint: false keeps every call on AWS_ENDPOINT_URL. The SDK even suggests it in the warning.
State
A restart empties everything. The community image keeps all resources in memory. docker compose restart localstack brings back the bucket and the queue, because the init script runs again, but every object and message is gone. The worker loses its connection during the restart and exits, and restart: on-failure starts it again. Treat LocalStack like a test database: anything your app needs at startup belongs in an init script, and anything else is disposable. Mounting a volume at /var/lib/localstack keeps the image's cache, not your data. Persistence is only available in the paid editions.
Services
Only the services you list exist. With SERVICES=s3,sqs, a call to anything else fails, including calls you did not know your tools made:
docker compose exec localstack awslocal sts get-caller-identityAn error occurred (InternalFailure) when calling the GetCallerIdentity operation: Service 'sts' is not enabled. Please check your 'SERVICES' configuration variable.The Terraform AWS provider, for one, calls STS as soon as it starts, so add sts and iam to the list before using it.
LocalStack is not AWS. It does not enforce IAM policies by default, it does not apply service quotas, and its error messages sometimes differ. A test that passes locally proves your code calls the right APIs in the right order. It does not prove that the production role is allowed to make those calls.
Where to go next
- More services: DynamoDB, SNS, Secrets Manager and Lambda are all in the community image. Add them to
SERVICESand create them in the init script. Lambda runs functions in their own containers, so it also needs the Docker socket mounted into LocalStack. - Infrastructure as code:
tflocalandcdklocalwrap Terraform and the CDK with the LocalStack endpoints, so the same templates that build production can build your local copy. - Tests: start LocalStack with Testcontainers inside your test suite, and every test run gets a fresh, empty AWS of its own.
- A clean slate:
docker compose down && docker compose up -d --waitrebuilds everything in about ten seconds, which is the best argument for keeping every resource in the init script.