Docker Deployment Guide
Container deployment shapes around the implemented wats serve contract — the shipped Railway Dockerfile, future Compose patterns, current PaaS support.
planned · reviewed 2026-07-05
Planned. The commands on this page do not run today; they document the intended shape. The repo ships a Railway-targeted Dockerfile for Railway deploys; production container publication is not supported.
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. The repo ships a Railway-targeted root Dockerfile that wraps the serve contract. There is still no supported Compose file, container image release, production hosting contract, or container-registry workflow.
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.
Future Dockerfile shape
A future Bun-first Dockerfile should follow this pattern once live/deploy packaging is authorized:
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"]This is not a supported root Dockerfile. It is a future shape only.
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:
- ALLThis is not a supported Compose file. It is a future shape only.
Managed 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.
Future smoke checks
Once a real 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.jsonThese are future commands only; Docker is not required in CI.