# 7. Deployment

Two supported topologies from the same images:

| Topology | When | How |
|----------|------|-----|
| Single server (on-prem, pilot) | one warehouse site, up to a few hundred users | `docker compose` stack in `infra/docker` (Postgres, API+worker, web/nginx) |
| Kubernetes (cloud, SaaS) | several tenants, HA, autoscaling | kustomize manifests in `infra/k8s` (API deployment + HPA, single worker, web, migrate job, ingress; Postgres as managed service recommended) |

## 7.1 Images

- `infra/docker/api.Dockerfile`: multi-stage pnpm build, production node_modules via `pnpm deploy`, runs as non-root with tini; `CMD node dist/main.js`; migrations via `node dist/db/migrate.js`; seed via `node dist/db/seed.js [--demo]`.
- `infra/docker/web.Dockerfile`: static Vite build served by nginx; `/api/*` proxied to `API_UPSTREAM` (default `http://api:3000`); hashed assets cached one year, `index.html` never cached.
- CI (`.github/workflows/ci.yml`) runs typecheck, unit and e2e tests against a Postgres service, builds the web app, and on `main` pushes both images to GHCR.

Note: the Docker daemon was not available in the build environment where this package was assembled; the Dockerfiles follow the exact commands validated locally (`pnpm --filter @wms/api build`, `pnpm --filter @wms/api --prod deploy --legacy`, `node dist/main.js`). Build them once in CI before the first deployment.

## 7.2 Environment variables (API)

| Variable | Required | Description |
|----------|----------|-------------|
| `NODE_ENV` | yes | `production` in all deployed environments (`mock` AI provider is refused) |
| `PORT`, `API_PREFIX` | no | default 3000, `api` |
| `CORS_ORIGINS` | yes | comma-separated origins of the web app |
| `DATABASE_URL` | yes | `postgresql://wms_app:<pw>@host:5432/wms` (application role, RLS enforced) |
| `DATABASE_MIGRATION_URL` | migrations only | owner/superuser connection used by `migrate.js` |
| `DATABASE_POOL_MAX` | no | per process, default 20 |
| `JWT_ACCESS_SECRET` | yes | 32+ random characters |
| `JWT_ACCESS_TTL`, `JWT_REFRESH_TTL_DAYS`, `COOKIE_SECURE` | no | defaults `15m`, 30, `false`; set `COOKIE_SECURE=true` behind TLS |
| `ENCRYPTION_KEY` | yes | base64 of 32 random bytes (`openssl rand -base64 32`); rotating it requires re-entering connector secrets |
| `PLATFORM_ADMIN_EMAIL`, `PLATFORM_ADMIN_PASSWORD` | first boot | bootstrap platform admin created by the seed |
| `STORAGE_DRIVER` + `STORAGE_LOCAL_PATH` or `S3_*` | yes | documents, labels, attachments; S3-compatible (AWS, MinIO) for multi-replica deployments |
| `MAIL_DRIVER` + `SMTP_*`, `MAIL_FROM` | yes for emails | RCR, invoices, discrepancy emails |
| `AI_PROVIDER`, `ANTHROPIC_API_KEY`, `AI_MODEL`, `AI_MAX_TOKENS` | optional | see `docs/05-ai-layer.md` |
| `JOBS_ENABLED` | yes | `true` on exactly one worker, `false` on API replicas |
| `OUTBOUND_ALLOW_PRIVATE_HOSTS` | no | default `false` (webhooks/ERP push to private networks are refused: SSRF protection); set `true` on-prem when the ERP is on the LAN |
| `LOG_LEVEL` | no | `info` |

## 7.3 Single server with docker compose

```bash
cd infra/docker
# edit ../../apps/api/.env.docker (secrets, DB passwords, CORS, SMTP) and postgres-init.sql (wms_app password must match DATABASE_URL)
docker compose up -d postgres
docker compose run --rm migrate
docker compose run --rm seed            # creates plans + platform admin + the demo tenant (the compose service passes --demo; remove it for production)
docker compose up -d api web
```

Web on `http://<server>:8080`, platform console at `/platform/login`. Put TLS in front (Caddy, Traefik, or nginx with certbot) and set `COOKIE_SECURE=true` and `CORS_ORIGINS=https://<host>`.

Backups: `pg_dump -Fc wms` nightly (cron on the host) plus the `storage` volume; test restore quarterly. Upgrade: pull new images, `run --rm migrate`, `up -d api web` (migrations are additive and backwards compatible within a minor version).

## 7.4 Kubernetes

```bash
kubectl apply -k infra/k8s/base                     # or overlays/prod with pinned image tags
kubectl -n westore-wms create secret generic wms-secrets --from-env-file=secrets.env
kubectl -n westore-wms apply -f infra/k8s/base/migrate-job.yaml
```

- `wms-api`: 2+ replicas, HPA on CPU, readiness on `/api/health/ready`, read-only root filesystem.
- `wms-worker`: 1 replica, `JOBS_ENABLED=true` (outbox, senders, snapshots, replenishment, sweeps). Scaling to 2 is safe (SKIP LOCKED) but unnecessary.
- `wms-web`: nginx, proxies `/api` to the API service (or route `/api` at the ingress, both are configured).
- Postgres: the provided StatefulSet is for evaluation; production should use a managed PostgreSQL 15+/16 with automated backups, PITR and connection pooling (PgBouncer in transaction mode is fine: the API uses `set_config(..., true)` which is transaction local).
- Object storage: S3-compatible bucket; set `STORAGE_DRIVER=s3`.
- Argo CD / Flux: the migrate job carries a PreSync hook annotation.

## 7.5 Day-2 operations

| Task | How |
|------|-----|
| Provision a tenant | Platform console > Tenants > Provision (plan, admin, first warehouse) or `POST /api/platform/tenants` |
| Change plan / features | Platform console > tenant > Subscription / Features; quotas apply within a minute |
| Rotate secrets | `JWT_ACCESS_SECRET` (forces re-login), API keys (create new, revoke old), webhook secrets (recreate subscription) |
| Export a tenant | Platform console > tenant > Export data (zip of NDJSON + manifest) |
| Watch health | `/api/health` (db latency), `/api/health/ready`; dashboards on `outbox_events` DEAD, `webhook_deliveries` DEAD, `integration_messages` FAILED, `storage_snapshots` gaps |
| Logs | pino JSON on stdout; correlate with `requestId` returned in every error |
| Database maintenance | autovacuum defaults are fine; index on `inventory_movements (tenant_id, performed_at)` supports the register; archive `audit_logs` and `outbox_events` older than 12 months if volume requires |
| Upgrades | migrate job, then rolling deployment; migrations are forward-only (no down scripts); take a backup first |

## 7.6 Sizing guidance (starting points)

| Scale | API | Worker | Postgres |
|-------|-----|--------|----------|
| 1 site, 50 RF users, 5k order lines/day | 2 x 0.5 vCPU / 512 MB | 1 x 0.5 vCPU / 512 MB | 2 vCPU / 4 GB, 50 GB |
| 5 sites, 300 users, 50k lines/day | 4 x 1 vCPU / 768 MB (HPA to 8) | 1 x 1 vCPU / 768 MB | 4 vCPU / 16 GB, 200 GB, read replica for reports |

The hot paths (allocation, ledger posting) are single short transactions with row locks; throughput scales with Postgres IOPS more than with API CPU.
