Running S3 and SQS Locally With LocalStack
The API starts against LocalStack, but the first upload fails with getaddrinfo ENOTFOUND uploads-local.localstack, the queue consumer logs InvalidClientTokenId: The security token included in the request is invalid, or every call succeeds and writes to a real AWS account because the endpoint override was never picked up. These are the three classic LocalStack wiring errors, and each has a one-line cause. This page wires S3 and SQS through LocalStack correctly and shows how to prove requests never leave the laptop. It is part of emulating cloud services locally.
The configuration below uses the AWS SDK for JavaScript v3 and boto3 as examples, but the same environment variables apply to every current AWS SDK and to the AWS CLI.
Diagnostic
Check what the application container actually sees and where its requests go:
#!/usr/bin/env bash
set -euo pipefail
docker compose exec -T api env | grep -E '^AWS_' | sed -E 's/(SECRET_ACCESS_KEY=).*/\1***/'
docker compose exec -T api node -e "
const { S3Client, ListBucketsCommand } = require('@aws-sdk/client-s3');
new S3Client({}).send(new ListBucketsCommand({})).then(r => console.log('buckets', r.Buckets.map(b => b.Name))).catch(e => console.log(e.name, e.message));"
docker compose logs localstack --since 2m | grep -E 'AWS (s3|sqs)\.' | tail -3 || echo "no requests reached localstack"
Expected bad output when the endpoint is missing and host credentials leaked in:
AWS_REGION=eu-west-1
AWS_PROFILE=dev
buckets [ 'acme-prod-logs', 'acme-dev-uploads' ]
no requests reached localstack
The container has no AWS_ENDPOINT_URL, it inherited a real profile, and it listed real buckets. That is the dangerous version of this failure: nothing errors, and local test data lands in a real account.
Root cause
AWS SDKs build the request URL from the region and, for S3, the bucket name as a subdomain: https://uploads-local.s3.eu-west-1.amazonaws.com. When the endpoint is overridden to http://localstack:4566, virtual-hosted addressing turns that into http://uploads-local.localstack:4566, a name Docker's DNS cannot resolve — hence ENOTFOUND. Path-style addressing (http://localstack:4566/uploads-local/key) avoids the subdomain. InvalidClientTokenId comes from real AWS rejecting the fake test credentials, which proves the endpoint override was not applied to that client. And when no override exists and a real profile is present, the SDK happily talks to AWS. All three are configuration, not LocalStack, problems.
The reason these errors are so common is that each SDK client is constructed separately. A codebase with an S3 client in the upload module, another in the export job and an SQS client in the worker has three places where configuration is read. Before environment-based endpoint overrides existed, each one needed an explicit endpoint option, and it was routine for one of them to be missed — the upload path would hit LocalStack while the nightly export quietly talked to a real bucket. The environment variable fixes that for every client at once, but only if the variable reaches every container that constructs a client, which is why the configuration below lives in one shared extension field rather than being copied into each service.
SQS has its own subtlety. A queue URL contains a host, and the URL returned by LocalStack reflects the hostname the request came in on. A queue created from the host through localhost:4566 has a URL containing localhost, which is useless inside a container. Creating resources from inside the LocalStack container, as the init hook below does, and passing queue URLs that use the localstack service name keeps every consumer on the network-reachable address.
Resolution
- Run LocalStack with a healthcheck and only the services you use, which shortens startup:
services:
localstack:
image: localstack/localstack:3.7
ports:
- "127.0.0.1:4566:4566"
environment:
SERVICES: s3,sqs
PERSISTENCE: "0"
volumes:
- ./localstack/init:/etc/localstack/init/ready.d:ro
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://localhost:4566/_localstack/init/ready | grep -q '\"completed\": true'"]
interval: 3s
retries: 40
- Configure the application through environment variables only, with fake credentials and path-style S3:
x-aws-local: &aws-local
AWS_REGION: us-east-1
AWS_ACCESS_KEY_ID: test
AWS_SECRET_ACCESS_KEY: test
AWS_ENDPOINT_URL: http://localstack:4566
AWS_S3_FORCE_PATH_STYLE: "true"
services:
api:
build: ./api
environment:
<<: *aws-local
UPLOADS_BUCKET: uploads-local
JOBS_QUEUE_URL: http://localstack:4566/000000000000/jobs
depends_on:
localstack:
condition: service_healthy
AWS_S3_FORCE_PATH_STYLE is read by your code, not by the SDK — pass it to the client constructor, which is the one place a local setting reaches code:
const { S3Client } = require('@aws-sdk/client-s3');
const s3 = new S3Client({ forcePathStyle: process.env.AWS_S3_FORCE_PATH_STYLE === 'true' });
module.exports = { s3 };
In boto3, set config=Config(s3={'addressing_style': 'path'}) under the same condition. Alternatively, use the hostname s3.localhost.localstack.cloud, which LocalStack resolves including bucket subdomains — but that needs public DNS, so path-style is the offline-safe choice.
- Create the resources in
localstack/init/10-resources.sh(make it executable):
#!/usr/bin/env bash
set -euo pipefail
awslocal s3api head-bucket --bucket uploads-local 2>/dev/null || awslocal s3 mb s3://uploads-local
awslocal sqs create-queue --queue-name jobs --attributes VisibilityTimeout=60 >/dev/null
- Block real profiles from leaking in. Never mount
~/.awsinto application containers, and unset profile variables explicitly in the extension field if the Compose file is used from shells that export them:AWS_PROFILE: ""is ignored by some SDKs, so prefer removing the variable from the environment passed to Compose.
Expected output
$ docker compose exec -T api node -e "..." # ListBuckets as in the diagnostic
buckets [ 'uploads-local' ]
$ docker compose logs localstack --since 1m | grep -E 'AWS (s3|sqs)\.' | tail -2
localstack-1 | 2026-09-18T09:14:03.211 INFO --- [et.reactor-0] localstack.request.aws : AWS s3.PutObject => 200
localstack-1 | 2026-09-18T09:14:03.402 INFO --- [et.reactor-1] localstack.request.aws : AWS sqs.SendMessage => 200
Only the local bucket is listed, and LocalStack's request log shows each S3 and SQS call — proof the traffic stayed local.
Prevention
Fail on real credentials. Add a startup assertion to the application's local configuration: if
AWS_ENDPOINT_URLis set,AWS_ACCESS_KEY_IDmust equaltest. A mismatch means someone mixed real credentials into a local run.Test the wiring in CI. A job that brings up the stack and asserts that LocalStack logged at least one
s3.PutObjectcatches a regression where a new client was constructed without the configuration.Bind LocalStack to loopback (
127.0.0.1:4566:4566) so an unauthenticated AWS-compatible endpoint is not exposed to the office network.
Platform caveats
Apple Silicon (ARM64):
localstack/localstackimages are multi-arch; no emulation is involved. Lambda functions defined with anx86_64architecture run under emulation inside LocalStack's Lambda containers and start slower.
macOS (Docker Desktop): scripts on the host reach LocalStack at
http://localhost:4566; containers usehttp://localstack:4566. Keep two endpoint values — one in.envfor host tooling, one in Compose for containers — rather than routing containers throughhost.docker.internal.
WSL2: if Docker Desktop's localhost forwarding is disabled, host scripts inside WSL cannot reach the published port on
localhost. Run host tooling in a container on the Compose network instead.
Rollback
Remove the override to return to a real development account, supplying credentials through your normal secret workflow:
#!/usr/bin/env bash
set -euo pipefail
git restore compose.yaml
docker compose rm -sf localstack
docker compose up -d api
Frequently Asked Questions
Why do I get ENOTFOUND bucket.localstack on S3 calls?
The SDK is using virtual-hosted addressing, which puts the bucket name in the hostname. Enable path-style addressing on the S3 client (forcePathStyle: true in JavaScript, addressing_style: 'path' in boto3) so the bucket goes in the URL path.
Which account ID does LocalStack use in queue URLs?
The default is 000000000000. Queue URLs look like http://localstack:4566/000000000000/jobs. Read the URL from get-queue-url in scripts rather than constructing it, so a change in LocalStack's defaults does not break anything.
Does LocalStack keep data between restarts?
Not with PERSISTENCE=0, which is the recommended setting for development: resources are recreated by init hooks on every start. Enabling persistence keeps state in a volume but makes the stack depend on hidden history, which is harder to reproduce.
How do I inspect objects and messages?
From the host, use the AWS CLI with --endpoint-url http://localhost:4566, or run awslocal inside the container: docker compose exec localstack awslocal sqs receive-message --queue-url http://localhost:4566/000000000000/jobs.