Seeding LocalStack Resources With Init Hooks
The worker starts a few hundred milliseconds before the queue exists and crashes with AWS.SimpleQueueService.NonExistentQueue: The specified queue does not exist, or the API's first upload fails with NoSuchBucket — but running docker compose restart api makes everything work. That is a seeding race: LocalStack reports healthy as soon as its services accept requests, which is before your resources have been created. This page creates LocalStack resources deterministically on every start and makes dependent services wait until seeding has completed. It is part of emulating cloud services locally.
The same pattern applies to any emulator that starts empty. LocalStack happens to provide first-class hooks for it, which makes it the clearest example.
Diagnostic
Compare the time LocalStack became ready, the time the seed script ran, and the time the dependent service made its first call:
#!/usr/bin/env bash
set -euo pipefail
docker compose down >/dev/null 2>&1 || true
docker compose up -d
sleep 20
docker compose logs --timestamps localstack | grep -E 'Ready\.|init|queue' | head -5
docker compose logs --timestamps worker | grep -iE 'NonExistentQueue|NoSuchBucket|started' | head -3
curl -fsS http://localhost:4566/_localstack/init | jq '.scripts[] | {stage, name, state}'
Expected bad output:
2026-09-18T09:30:01.412Z localstack-1 | Ready.
2026-09-18T09:30:01.655Z worker-1 | NonExistentQueue: The specified queue does not exist
2026-09-18T09:30:02.087Z localstack-1 | init: running 10-resources.sh
{ "stage": "READY", "name": "10-resources.sh", "state": "SUCCESSFUL" }
LocalStack was ready at .412, the worker failed at .655, and the seed script only started at 02.087. The seed eventually succeeded, which is why a restart "fixes" it.
Root cause
LocalStack runs lifecycle hooks at four stages — boot, start, ready and shutdown — from directories under /etc/localstack/init/. Scripts in ready.d run after the services are ready, because that is when awslocal calls work. A healthcheck that calls /_localstack/health reports healthy at the ready transition, before the ready.d scripts run, so depends_on: condition: service_healthy releases dependent containers into the gap. The fix is to make the healthcheck reflect seeding: LocalStack exposes the state of init scripts at /_localstack/init, including a completed flag per stage.
There is also a correctness issue in the scripts themselves. When the LocalStack volume persists between runs, or when the container restarts without being recreated, the scripts run again against existing resources. A script that uses create-only operations fails on the second run, LocalStack marks the stage as failed, and — with a healthcheck tied to completion — the stack never becomes healthy. Every seed command must therefore be idempotent.
Resolution
- Write idempotent seed scripts in a directory mounted at
ready.d. Useset -euo pipefailso a genuine failure stops the script and is reported:
#!/usr/bin/env bash
set -euo pipefail
ensure_bucket() { awslocal s3api head-bucket --bucket "$1" 2>/dev/null || awslocal s3api create-bucket --bucket "$1" >/dev/null; }
ensure_queue() { awslocal sqs create-queue --queue-name "$1" --attributes "$2" >/dev/null; }
ensure_bucket uploads-local
ensure_bucket exports-local
ensure_queue jobs-dlq '{"MessageRetentionPeriod":"1209600"}'
dlq_arn=$(awslocal sqs get-queue-attributes --queue-url "$(awslocal sqs get-queue-url --queue-name jobs-dlq --output text)" \
--attribute-names QueueArn --query Attributes.QueueArn --output text)
ensure_queue jobs "{\"VisibilityTimeout\":\"60\",\"RedrivePolicy\":\"{\\\"deadLetterTargetArn\\\":\\\"$dlq_arn\\\",\\\"maxReceiveCount\\\":\\\"5\\\"}\"}"
topic_arn=$(awslocal sns create-topic --name order-events --query TopicArn --output text)
awslocal sns subscribe --topic-arn "$topic_arn" --protocol sqs \
--notification-endpoint "arn:aws:sqs:us-east-1:000000000000:jobs" >/dev/null
echo "seed complete"
Save it as localstack/init/10-resources.sh and chmod +x it; LocalStack skips non-executable files silently.
- Gate the healthcheck on the ready stage having completed:
services:
localstack:
image: localstack/localstack:3.7
environment:
SERVICES: s3,sqs,sns
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: 2s
timeout: 3s
retries: 60
worker:
build: ./worker
depends_on:
localstack:
condition: service_healthy
- Optionally, seed from infrastructure code instead of a hand-written script.
tflocalwraps Terraform and points every AWS provider at LocalStack, so the same resource definitions production uses create the local resources:
#!/usr/bin/env bash
set -euo pipefail
pip install --quiet terraform-local
cd infra/local
tflocal init -input=false >/dev/null
tflocal apply -auto-approve -input=false
Run this from an init container on the Compose network with AWS_ENDPOINT_URL=http://localstack:4566, and make the application depend on that container with condition: service_completed_successfully.
Expected output
$ curl -fsS http://localhost:4566/_localstack/init/ready | jq '{completed, scripts: [.scripts[] | {name, state}]}'
{
"completed": true,
"scripts": [ { "name": "10-resources.sh", "state": "SUCCESSFUL" } ]
}
$ docker compose ps --format '{{.Service}} {{.Health}}'
localstack healthy
worker
$ docker compose logs worker | grep -c NonExistentQueue
0
The worker starts only after the seed script reports success, and it never sees a missing queue, on the first start or any restart.
It is worth reading the failure path as well. Introduce a deliberate typo in the script — an invalid attribute name on create-queue — and start the stack again. The init endpoint reports the script as ERROR, the completed flag never becomes true, the LocalStack container turns unhealthy after the retry budget, and docker compose up --wait exits non-zero with the LocalStack service named. That is the behaviour you want: a broken seed stops the stack at the source instead of surfacing twenty minutes later as an unrelated-looking error in the worker. The LocalStack log shows the exact failing command, so the fix is usually one line.
Timing is predictable too. On a laptop with a warm image cache, LocalStack with three services reaches ready in about two seconds and a script of this size adds under one more, so the gated healthcheck costs almost nothing compared with the restarts it prevents.
Prevention
Run the seed twice in CI. Start the stack, restart LocalStack without removing it, and assert that the ready stage still completes. This catches non-idempotent commands immediately.
Keep seed scripts next to the infrastructure code they mirror, and require both to change in the same pull request through a CODEOWNERS rule or a CI diff check.
Fail loudly.
set -euo pipefailplus the completion-gated healthcheck means a broken script makes the stack unhealthy with a visible error, rather than leaving a half-seeded emulator that fails later in confusing ways.
Platform caveats
Apple Silicon (ARM64):
tflocalruns in any Python image; use a multi-arch base such aspython:3.12-slimfor the init container.
macOS (Docker Desktop): scripts in bind-mounted directories keep the executable bit from the host. If a script was created on a filesystem that dropped it, LocalStack ignores it; check with
ls -l localstack/init.
WSL2: scripts edited on the Windows side may have CRLF line endings, which make the shebang fail with
/usr/bin/env: 'bash\r': No such file or directory. Add*.sh text eol=lfto.gitattributes, as described in fixing git line-ending errors.
Rollback
Return to an ungated healthcheck and remove the init mount; resources then need to be created manually or by the application:
#!/usr/bin/env bash
set -euo pipefail
git restore compose.yaml
docker compose up -d --force-recreate localstack
Frequently Asked Questions
Why does LocalStack report healthy before my resources exist?
The standard health endpoint reports service readiness, which happens before ready.d scripts run. Use /_localstack/init/ready and check completed: true in the healthcheck so dependent services wait for seeding.
My init script does not run at all. Why?
LocalStack only runs executable files. Check permissions with ls -l, make sure the directory is mounted at /etc/localstack/init/ready.d, and check for CRLF line endings, which break the shebang on Linux.
Should seed scripts run in boot.d or ready.d?
Use ready.d for anything that calls AWS APIs, because services are not accepting requests during boot and start. boot.d is for preparing the container itself, such as installing a tool.
Is tflocal worth the slower startup?
For stacks where resource attributes matter — queue timeouts, dead-letter policies, bucket notifications — yes, because drift between hand-written scripts and production causes bugs that look like application errors. For a single bucket, a short script is fine.