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.

Reading a LocalStack Wiring Failure Decision diagram mapping three LocalStack error symptoms to their causes. Reading a LocalStack Wiring Failure What happens on the first S3 call? ENOTFOUND bucket.host force path-style InvalidClientTokenId endpoint not applied Real buckets listed endpoint missing entirely
Each symptom corresponds to one missing setting, so the fix is always a single variable.

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

  1. 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
  1. 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.

  1. 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
  1. Block real profiles from leaking in. Never mount ~/.aws into 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.
A Correctly Routed S3 Upload Flow of an S3 PutObject call from the API container through the endpoint override to LocalStack storage. A Correctly Routed S3 Upload api container PutObject endpoint override localstack:4566 path-style URL /uploads-local/key LocalStack S3 object stored
Path-style addressing keeps the bucket name in the URL path, which Docker DNS can route.

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

  1. Fail on real credentials. Add a startup assertion to the application's local configuration: if AWS_ENDPOINT_URL is set, AWS_ACCESS_KEY_ID must equal test. A mismatch means someone mixed real credentials into a local run.

  2. Test the wiring in CI. A job that brings up the stack and asserts that LocalStack logged at least one s3.PutObject catches a regression where a new client was constructed without the configuration.

  3. Bind LocalStack to loopback (127.0.0.1:4566:4566) so an unauthenticated AWS-compatible endpoint is not exposed to the office network.

Configuration Layers for Local AWS Layered view of the settings that route AWS SDK calls to LocalStack. Configuration Layers for Local AWS Credentials test / test, never a profile Endpoint AWS_ENDPOINT_URL S3 addressing forcePathStyle Resources init hook creates them
Every layer is configuration; only the path-style flag touches code, through one constructor.

Platform caveats

Apple Silicon (ARM64): localstack/localstack images are multi-arch; no emulation is involved. Lambda functions defined with an x86_64 architecture 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 use http://localstack:4566. Keep two endpoint values — one in .env for host tooling, one in Compose for containers — rather than routing containers through host.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.