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:

terminal
mkdir localstack-demo && cd localstack-demo

The project layout

localstack-demo/
localstack-demo/
├── docker-compose.yml
├── init/
│   └── ready.d/
│       └── 01-resources.sh
├── files/
└── app/
    ├── Dockerfile
    ├── package.json
    ├── aws.mjs
    ├── upload.mjs
    └── worker.mjs

Two services run from it:

  1. localstack emulates S3 and SQS on port 4566 and runs the init script once both are ready.
  2. 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

docker-compose.yml
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=test

The 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:

output of localstack/localstack:latest without a token (trimmed)
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:

output
{"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:

output with a failing init script
dependency failed to start: container localstack-demo-localstack-1 is unhealthy

The 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

init/ready.d/01-resources.sh
#!/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:

terminal
chmod +x init/ready.d/01-resources.sh

On 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:

terminal
mkdir -p files
docker compose up -d --wait localstack

Naming the service starts only LocalStack. The first run downloads the image. After that, it is up in about ten seconds:

terminal
docker compose ps -a --format 'table {{.Service}}\t{{.Status}}'
output
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:

terminal
docker compose exec localstack awslocal s3 ls
output
2026-10-03 00:16:12 uploads

Every 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:

terminal
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:

output (trimmed)
[
    "{\"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:

app/package.json
{
  "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:

app/Dockerfile
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:

app/aws.mjs
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:

app/upload.mjs
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:

app/worker.mjs
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:

terminal
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 worker
output (trimmed)
Uploaded 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 lines

report.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:

output without forcePathStyle
Error: getaddrinfo ENOTFOUND uploads.localstack

forcePathStyle 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:

output without useQueueUrlAsEndpoint: false
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:4566

useQueueUrlAsEndpoint: 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:

terminal
docker compose exec localstack awslocal sts get-caller-identity
output
An 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 SERVICES and 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: tflocal and cdklocal wrap 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 --wait rebuilds everything in about ten seconds, which is the best argument for keeping every resource in the init script.