wats.sh
Guides

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 on oven/bun:1.3.13; Railway auto-detects it.
  • railway.json (repo root) — config-as-code: Dockerfile builder + /healthz healthcheck.
  • deploy/railway/entrypoint.sh — maps Railway's $PORT onto wats serve --host 0.0.0.0 --port $PORT.
  • deploy/railway/wats.config.yaml — a 0.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-file guard, then runs bun /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=live

PORT 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 domain

Then 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:
      - ALL

Future 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.json

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"]
  • --paas takes the bind port from $PORT (the platform sets it) and binds 0.0.0.0 by default.
  • Pass --host/--port explicitly to override the PaaS defaults.
  • Serve fails closed if --paas needs $PORT but it is missing or not 1..65535.
  • Without --paas, $PORT is ignored; local/default behavior is unchanged.

See the CLI reference for the full resolution rules.

Healthcheck and readiness

Use local service routes:

  • /healthz for liveness
  • /readyz for readiness
  • /openapi.json for 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_TOKEN

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

On this page