Local 2-pod active-active dev setup
Owner: backend infra Last updated: 2026-05-27 (#881 β full validation pass) Compose file:
docker-compose.multi-pod.ymlNginx config:scripts/local/nginx-multipod.confCross-pod test script:scripts/load/socketio-fanout-test.mjs
When this is usefulβ
The default docker compose up runs a single api container. That's fine for most dev, but it doesn't catch:
- Socket.io Redis adapter cross-pod fan-out bugs (PR #449)
- BullMQ jobId dedup races (P0-9e/9f, #827)
- Leader election (P0-9c) β needs > 1 pod to test at all
- Throttler Redis storage convergence (P0-9k)
- SIGTERM drain during rolling deploy (PR #836 / #808)
- DLQ retry worker idempotency under concurrent pods (#744)
For those, run the 2-pod local stack.
Pre-requisitesβ
The multi-pod compose builds the api container in the Dockerfile's default final stage (runner β compiled dist/), unlike the default override (docker-compose.override.yml) which sets target: builder for hot-reload. That means env vars required at boot must actually be set in .env. Common ones that may be missing in a stale local .env:
# Generate if missing β values shown here are placeholders, run openssl
# rand to get real ones, never commit:
JWT_SECRET=<openssl rand -hex 48>
MASTER_ENCRYPTION_KEY=<openssl rand -hex 32>
RUNNER_JWT_SECRET (api, #866) and HUMANWORK_ORG_SLUG / PLATFORM_API_TOKEN (agent, #870) have dev fallbacks β they're not required in NODE_ENV=development. Set them only if you want to mirror prod behaviour.
If any required secret is absent the api containers will start, fail boot with <NAME> is required for ..., and the LB will return 502. Check docker logs humanwork-api-1 after first boot if so.
Bootβ
# Bring up postgres + redis + agent + api-1 + api-2 + nginx-lb
docker compose \
-f docker-compose.yml \
-f docker-compose.multi-pod.yml \
up -d
# Watch both pods come up
docker compose logs -f api-1 api-2
http://localhost:3000 is the nginx LB; it round-robins between api-1:3000 and api-2:3000.
Verify it's actually 2 podsβ
# Each curl should land on a different pod β look for X-Pod-Endpoint
for i in 1 2 3 4; do
curl -sI http://localhost:3000/health | grep -i x-pod-endpoint
done
Expected output rotates between the two upstreams:
X-Pod-Endpoint: 172.18.0.5:3000
X-Pod-Endpoint: 172.18.0.6:3000
X-Pod-Endpoint: 172.18.0.5:3000
X-Pod-Endpoint: 172.18.0.6:3000
If both lines show the same IP, nginx fell back to a single upstream β check that both api containers are healthy: docker compose ps.
Smoke-test cross-pod behaviorβ
Socket.io fan-out (item #6)β
wscat doesn't speak the Socket.io protocol β use the bundled Node script instead. It opens two Socket.io clients (nginx round-robin lands them on different pods), publishes a synthetic agent_message_sent via @socket.io/redis-emitter, and asserts both sockets receive it.
cd scripts/load
npm install # one-time
npm run socketio-fanout
Expected output:
Socket.io cross-pod fan-out test β http://localhost:3000
β JWT acquired (superadmin β 'experts' room)
β A: connected sid=...
β B: connected sid=...
β publishing via @socket.io/redis-emitter
β A received agent_message_sent
β B received agent_message_sent
PASS β Redis adapter cross-pod fan-out verified
Exit code 0 = pass, 1 = fan-out broken. The script auto-acquires a superadmin JWT via /auth/demo-login and reads the Redis password from env (REDIS_PASSWORD, default HwDev2026).
If it fails: check REDIS_URL is set on both pods, and that PUBSUB CHANNELS '*' on Redis lists socket.io-request#/notifications# plus one socket.io-response#/notifications#<uid># per pod (two if 2 pods are subscribed).
Pod-kill resilience (item #11)β
# Start a slow load
while true; do curl -sf http://localhost:3000/health > /dev/null; sleep 0.1; done &
LOAD_PID=$!
# Kill one pod mid-stream
docker stop humanwork-api-1
# Verify the load script keeps getting 200s (nginx fails over to api-2)
# After ~10s, expect zero errors in your terminal
# Restart the killed pod
docker start humanwork-api-1
# Watch logs β leader election should rebalance cleanly:
docker compose logs api-1 | grep -i 'leader\|election'
# Stop load
kill $LOAD_PID
Pass criteria: no failed curl, no double-cron-fire after restart, no socket message duplication.
Leader election (P0-9c)β
The leader lock value is a hostname-derived id (e.g. b49b4a048d45-1-4psjd0), not the literal api-1/api-2. Match it against docker exec humanwork-api-1 hostname / humanwork-api-2 hostname.
The Redis password in .env (REDIS_PASSWORD) is not the compose default β locally it's typically HwDev2026. Extract it once from a running pod's REDIS_URL:
PW=$(docker exec humanwork-api-1 sh -c 'echo $REDIS_URL' \
| sed -E 's|.*://:([^@]+)@.*|\1|')
# Current leader + TTL
docker exec -e PW="$PW" humanwork-redis sh -c \
'redis-cli -a "$PW" --no-auth-warning GET hwork:leader:lock'
docker exec -e PW="$PW" humanwork-redis sh -c \
'redis-cli -a "$PW" --no-auth-warning TTL hwork:leader:lock'
Failover recipe (verified working in #881):
# Identify leader β kill it
LEADER_HOST=$(docker exec -e PW="$PW" humanwork-redis sh -c \
'redis-cli -a "$PW" --no-auth-warning GET hwork:leader:lock' \
| cut -d- -f1)
if [ "$(docker exec humanwork-api-1 hostname)" = "$LEADER_HOST" ]; then
docker stop humanwork-api-1
else
docker stop humanwork-api-2
fi
# Wait > LEADER_LOCK_TTL (30s) for the lock to expire, then verify the
# other pod has taken over (new value, fresh TTL ~28s):
sleep 35
docker exec -e PW="$PW" humanwork-redis sh -c \
'redis-cli -a "$PW" --no-auth-warning GET hwork:leader:lock'
docker exec -e PW="$PW" humanwork-redis sh -c \
'redis-cli -a "$PW" --no-auth-warning TTL hwork:leader:lock'
# Recovery check (no split-brain): restart the stopped pod, verify it
# does NOT steal the lock β count of BullMQ scheduler keys should stay
# the same (~80, not ~160):
docker start humanwork-api-1 # or api-2
sleep 20
docker exec -e PW="$PW" humanwork-redis sh -c \
'redis-cli -a "$PW" --no-auth-warning KEYS "bull:scheduler:repeat:*" | wc -l'
Pass criteria: leader value changes after 35 s wait; lock TTL fresh (~28 s) after failover; scheduler key count stable after restart.
NFR smoke against 2-pod localβ
# After standing up the multi-pod stack:
TOKEN_A=$(curl -sS -X POST http://localhost:3000/auth/demo-login \
-H 'Content-Type: application/json' \
-d '{"role":"org_admin","orgSlug":"acmefinancial"}' | jq -r '.token')
TOKEN_B=$(curl -sS -X POST http://localhost:3000/auth/demo-login \
-H 'Content-Type: application/json' \
-d '{"role":"org_admin","orgSlug":"techcorp"}' | jq -r '.token')
STAGING_BASE_URL=http://localhost:3000 \
STAGING_FRONTEND_URL=http://localhost:3001 \
STAGING_TOKEN_ORG_A="$TOKEN_A" \
STAGING_TOKEN_ORG_B="$TOKEN_B" \
bash scripts/test-nfr-smoke.sh
Expected on the local stack (post-#879): 6 PASS / 0 FAIL / 8 SKIP, exit 0.
- Items 1, 4, 8, 9 PASS unconditionally
- Item 3 (cross-org IDOR) SKIPs because local
/auth/demo-loginreturns the same token regardless oforgSlugβ the probe needs two distinct real users; on staging it runs normally - Items 6 and 11 stay SKIP in the smoke script β but the cross-pod recipes above cover them manually. Items 5, 7, 10, 12 require infra (Sentry, RDS, channel_failures seed, real queue item) and only run against staging.
What's now validated locallyβ
| MVP P0 NFR | Validated by | Script |
|---|---|---|
| P0-3 Socket.io Redis adapter | Cross-pod fan-out (Β§Socket.io fan-out) | scripts/load/socketio-fanout-test.mjs |
| P0-9c Leader election | Failover + no split-brain (Β§Leader election) | shell recipe |
| P0-9e/9f BullMQ jobId dedup | Scheduler key count stable across pod restart | shell recipe |
| P0-11 Pod kill mid-deploy | 50 reqs through nginx, 0 errors (Β§Pod-kill resilience) | shell recipe |
| /health, /ready reachability, webhook dedup, prod-invariant grep | scripts/test-nfr-smoke.sh | smoke script |
Things still needing staging:
- P0-1 Sentry alert path β real DSN
- P0-2 DLQ retry β real
channel_failuresrows - P0-9k Throttler convergence β multi-pod soak with high QPS
- RDS Multi-AZ failover (P0-?) β AWS infra
Tear downβ
docker compose \
-f docker-compose.yml \
-f docker-compose.multi-pod.yml \
down
Then go back to the default override (single api pod):
docker compose up -d
Gotchasβ
- Don't mix the multi-pod compose with
docker-compose.override.ymlβ the override defines a singleapiservice with hot-reload volumes; combining them would reactivate that service alongside api-1 / api-2 and you'd have 3 pods competing for the same Redis keys. The override is automatically picked up unless you pass-f docker-compose.yml -f docker-compose.multi-pod.ymlexplicitly (this excludesdocker-compose.override.yml). - Hot reload isn't wired on api-1 / api-2 β each container builds the default final stage (
runner) ofapi/Dockerfile, not thebuilderstage that the devdocker-compose.override.ymluses. To pick up code changes: rebuild withdocker compose -f docker-compose.yml -f docker-compose.multi-pod.yml up -d --build. - Single Postgres / single Redis β this is local convenience, not a Multi-AZ test. NFR smoke item #7 (RDS failover) stays SKIP locally.
- Single agent β agent is stateless, multi-pod adds no signal there. Keep
agentas one container. - Single Frontend β same logic, no Next.js multi-pod story to test locally.
- Healthcheck IPv6/IPv4 trap (lesson from #872/#874) β in
python:slimandalpinebase images,localhostresolves to::1only. If the service under check binds IPv4-only (Next.js dev server, nginxlisten 80;),wget http://localhost:...will fail Connection refused even when the service is up. Always use127.0.0.1in composehealthcheck.testfor containers whose service binds IPv4-only. Node's HTTP server binds dual-stack:::3000so the api healthcheck is unaffected βnetstat -tlninside the container tells you which side a given service is on. - Redis password is from
.env, not compose default β the compose${REDIS_PASSWORD:-Hw2026!Redis#SecureDefault}fallback is rarely used; local stacks typically haveREDIS_PASSWORD=HwDev2026(or whatever the team-shared.envsays). Extract it from a running pod:docker exec humanwork-api-1 sh -c 'echo $REDIS_URL' | sed -E 's|.*://:([^@]+)@.*|\1|'. The password contains!and#in the default β they break URL parsing in redis client libs, prefer passing as a separatepasswordfield.
Referenceβ
- ADR-018 Β§AA1βAA10 (multi-pod safety contract)
- NFR smoke runbook:
docs/runbooks/staging-smoke-runbook.md - #863 β issue tracking this setup
- #870 / #873 / #876 β boot bug fixes surfaced by this stack
- #879 β NFR smoke false-positive cleanup
- #881 β this runbook's validation pass