Docker Deployment Guide
The shipped Railway Dockerfile (active) and the generic Compose/registry shape (planned) for container deployment around the wats serve contract.
shape-only — Railway Dockerfile ships; generic Compose/registry shape is planned · reviewed 2026-07-20
The repo ships a Railway-targeted root Dockerfile that wraps the
wats serve contract. The Railway section below documents real, shipped
artifacts. The generic Compose/registry shape further down is planned — those
commands do not run today.
wats serve --config <path> --dry-run is implemented for local Bun smoke
checks, and
wats serve --config <path> --live --yes-live --env-file .env.local
is implemented for local live testing behind a secure HTTPS tunnel.
Railway (shipped Dockerfile)
Shape-only — deployed by maintainers, not a supported contract. The artifacts below are real and ship from the repo root, but container publication is not supported and the image is not exercised in CI.
What ships
Dockerfile(repo root) — Bun multi-stage build onoven/bun:1.3.13; Railway auto-detects it.railway.json(repo root) — config-as-code: Dockerfile builder +/healthzhealthcheck.deploy/railway/entrypoint.sh— maps Railway's$PORTontowats serve --host 0.0.0.0 --port $PORT.deploy/railway/wats.config.yaml— a0.0.0.0:8080-binding service profile with env-secret refs.
Image build
The Dockerfile is a two-stage Bun build. The builder stage installs deps and
runs bun run build:packages (produces packages/*/dist). The runtime stage
copies the built tree into oven/bun:1.3.13-slim and sets:
ENV NODE_ENV=production \
WATS_SERVE_MODE=dry-run \
WATS_CONFIG=/app/deploy/railway/wats.config.yaml
ENV PORT=8080
EXPOSE 8080
ENTRYPOINT ["/app/deploy/railway/entrypoint.sh"]Modes
WATS_SERVE_MODE=dry-run(default) — synthetic in-memory secrets, no live Meta calls. Safe for verifying the deploy, healthcheck, and routing.WATS_SERVE_MODE=live— resolves real credentials from the service env and uses the fetch-backed Graph transport. The entrypoint synthesizes a temp.env.local(umask 077, trap-cleaned) to satisfy the CLI's--live --yes-live --env-fileguard, then runsbun /app/packages/cli/dist/bin.js serve.
Required Railway service variables (live mode)
Set these in the Railway service Variables UI — never commit them:
WATS_ACCESS_TOKEN=... # Meta WhatsApp access token
WATS_APP_SECRET=... # Meta app secret (webhook HMAC)
WATS_VERIFY_TOKEN=... # webhook verify token (you choose this)
WATS_SERVICE_TOKEN=... # bearer token for authenticated message routes
WATS_WABA_ID=... # WhatsApp Business Account id
WATS_PHONE_NUMBER_ID=... # phone number id
WATS_SERVE_MODE=livePORT is injected by Railway automatically — do not set it.
Deploy
railway init # create/select project in your workspace
railway up # build + deploy from this repo (uses Dockerfile)
railway domain # generate the public HTTPS domainThen point Meta App Dashboard > WhatsApp > Configuration at:
Callback URL: https://<your-service-domain>/webhooks/whatsapp
Verify token: <the WATS_VERIFY_TOKEN value you set>Local smoke test
docker build -t wats-railway .
docker run --rm -e PORT=9090 -p 9090:9090 wats-railway
curl http://127.0.0.1:9090/healthz # {"ok":true,"service":"wats"}Fork-friendly: delete Dockerfile, railway.json, and deploy/railway/ to
strip Railway support. Nothing in the SDK packages depends on these files.
Safety defaults
- no live Meta calls during build
- no live Meta calls during tests
- no secrets baked into images
- no registry credentials in normal CI
- no image publication
- env-secret references only
- do not commit
.env - do not pass raw secrets as CLI arguments
mTLS and the HMAC boundary
Container packaging does not change the webhook security split. WATS verifies
incoming Meta webhook POSTs at the app layer with HMAC-SHA256 from
X-Hub-Signature-256; preserve the raw body so that validation still
succeeds.
Optional Meta webhook mTLS is a client-certificate concern for the ingress in
front of the container: reverse proxy, load balancer, service mesh, CDN,
Kubernetes ingress, or other TLS terminator. Operators who enable it must
configure trust for Meta's owned root meta-outbound-api-ca-2025-12.pem
outside WATS. WATS does not vendor the CA, bake PEM contents into images, or
configure your infrastructure. Obtain and rotate the CA from Meta's
authoritative channel rather than committing certificate material.
Planned: generic Compose/registry shape
Planned. The Dockerfile and compose file below are not shipped and the commands do not run today; they document the intended shape once live/deploy packaging is authorized.
Future Dockerfile shape
A future Bun-first Dockerfile should follow this pattern:
FROM oven/bun:1 AS deps
WORKDIR /app
COPY package.json bun.lock ./
COPY packages ./packages
RUN bun install --frozen-lockfile
FROM oven/bun:1 AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app /app
RUN addgroup --system --gid 10001 wats && adduser --system --uid 10001 --ingroup wats wats
USER 10001:10001
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD bun -e "const r=await fetch('http://127.0.0.1:3000/healthz'); process.exit(r.ok ? 0 : 1)"
CMD ["bun", "run", "wats", "serve", "--config", "/app/config/wats.config.yaml", "--profile", "prod", "--host", "0.0.0.0", "--port", "3000"]Future compose.yaml shape
A future compose file should inject runtime environment values rather than baking secrets into the image:
services:
wats:
image: wats:local
command:
- wats
- serve
- --config
- /app/config/wats.config.yaml
- --profile
- prod
- --host
- 0.0.0.0
- --port
- "3000"
ports:
- "127.0.0.1:3000:3000"
environment:
WATS_ACCESS_TOKEN: ${WATS_ACCESS_TOKEN:?set outside repo}
WATS_VERIFY_TOKEN: ${WATS_VERIFY_TOKEN:?set outside repo}
WATS_APP_SECRET: ${WATS_APP_SECRET:?set outside repo}
WATS_SERVICE_TOKEN: ${WATS_SERVICE_TOKEN:?set outside repo}
volumes:
- ./wats.config.yaml:/app/config/wats.config.yaml:ro
read_only: true
tmpfs:
- /tmp
security_opt:
- no-new-privileges:true
cap_drop:
- ALLFuture smoke checks
Once a real generic Dockerfile exists, the credential-free verification is:
docker build -t wats:local .
docker run --rm -p 127.0.0.1:3000:3000 wats:local
curl -fsS http://127.0.0.1:3000/healthz
curl -fsS http://127.0.0.1:3000/readyz
curl -fsS http://127.0.0.1:3000/openapi.jsonManaged PaaS platforms (--paas)
Managed platforms (Railway, Fly, Render, Cloud Run) inject a $PORT env var
and require the process to bind 0.0.0.0. wats serve --paas reads $PORT
and defaults the bind host to 0.0.0.0, so no entrypoint shim is needed to
map the platform port onto the static --host/--port flags:
# Container CMD for a PaaS that injects $PORT:
CMD ["bun", "run", "wats", "serve", "--config", "/app/config/wats.config.yaml", "--profile", "prod", "--live", "--yes-live", "--env-file", ".env.local", "--paas"]--paastakes the bind port from$PORT(the platform sets it) and binds0.0.0.0by default.- Pass
--host/--portexplicitly to override the PaaS defaults. - Serve fails closed if
--paasneeds$PORTbut it is missing or not1..65535. - Without
--paas,$PORTis ignored; local/default behavior is unchanged.
See the CLI reference for the full resolution rules.
Healthcheck and readiness
Use local service routes:
/healthzfor liveness/readyzfor readiness/openapi.jsonfor service OpenAPI smoke checks
Do not put tokens in a healthcheck. Do not make a healthcheck call Meta Graph.
Env-secret references
Config should refer to env names, not secret values:
auth:
accessToken:
env: WATS_ACCESS_TOKEN
webhook:
verifyToken:
env: WATS_VERIFY_TOKEN
appSecret:
env: WATS_APP_SECRET
service:
bearerToken:
env: WATS_SERVICE_TOKENFuture Postgres deployment should use an env-secret reference such as
WATS_DATABASE_URL; database URLs are secrets and must not be printed in
logs.
Volumes and persistence
WATS has an experimental @wats/persistence/sqlite local adapter, but
@wats/service does not consume it yet. Future container examples should
mount a writable data directory such as /var/lib/wats for SQLite
local/single-instance testing. Future multi-replica deployments should use
Postgres once the adapter and service integration exist.
Non-root runtime
Future container artifacts should run as a non-root user, bind a high port, avoid privileged mode, and avoid Docker socket mounts. The app source should be read-only where practical.