Skip to content

Deployment Guide

This guide covers running Whity Core outside a developer machine: the staging stack, load testing, and how the server sees client IP addresses behind a proxy.

Whity Core ships as container images. To move an install to a new version, see Core Update. There is no API for applying or rolling back code.

An isolated, prod-like staging stack and a k6 load-testing harness ship with the repo. The staging stack runs on non-default host ports so it never collides with the dev demo (:8000 / :5432).

docker-compose.staging.yml brings up FrankenPHP + PostgreSQL under a distinct project name (whity-staging), distinct volume (postgres_data_staging), and alt ports: frankenphp :8100, postgres :5433. It is prod-like — APP_ENV=staging (not development), so the JWT_SECRET (>= 32 chars) and ENCRYPTION_KEY fail-fast guards in public/index.php engage. Secrets, the explicit CORS_ALLOWED_ORIGINS, and INITIAL_*_PASSWORD seed creds are supplied via an env file.

Terminal window
# 1. Configure secrets (do NOT commit the populated .env.staging)
cp .env.staging.example .env.staging # edit secrets; JWT/ENCRYPTION >= 32 chars
# 2. Bring it up (build + start). Note: in a git worktree without vendor/,
# copy it first (cp -r <main-checkout>/vendor ./vendor) so the workers boot.
docker compose -p whity-staging -f docker-compose.staging.yml \
--env-file .env.staging up -d --build
# or: make staging-up
# 3. Migrate + seed
./scripts/init-staging-db.sh # or: make staging-init
# 4. Verify
curl http://localhost:8100/api/health # -> 200 {"status":"ok",...}
curl -i http://localhost:8100/api/admin/stats # -> 401 (auth required)
# Tear down (keep data): docker compose -p whity-staging -f docker-compose.staging.yml down
# Tear down (wipe data): make staging-down ARGS=-v

A k6 harness lives under load-tests/ and runs from the grafana/k6 Docker image (no repo dependency). It exercises GET /api/health, the POST /api/login -> authenticated GET /api/admin/stats flow, and the RBAC-gated GET /api/roles.

Auth-flow notes (WC-160):

  • POST /api/login (and the other auth POSTs) require the CSRF defense header X-Requested-With: XMLHttpRequest; the same header is required on any cookie-authenticated state-changing request. The k6 script already sends it — add it to any hand-rolled curl login flow.
  • Outside APP_ENV=development auth cookies carry the Secure flag, so cookie-based auth flows only work over HTTPS. The staging stack serves plain HTTP on :8100, which means strict cookie jars (k6, curl -c/-b) will not replay the auth cookie against it — run cookie-auth flows against an HTTPS-terminated deployment, or a stack started with APP_ENV=development.
Terminal window
VUS=10 DURATION=30s ADMIN_PASSWORD='<staging-admin-pw>' ./load-tests/run.sh
# or: make load-test

See load-tests/README.md for full usage and load-tests/BASELINE.md for committed baseline numbers (throughput, p95 latency, error rate).

The backend derives the client IP (for per-IP rate limiting and audit logs) from a single internal header, X-Whity-Client-Ip, and ignores raw client-supplied X-Forwarded-For / X-Real-IP. Those forwarding headers are attacker-controllable (the Next.js API proxy forwards arbitrary client headers), so trusting them let a caller spoof the rate-limit key and poison audit IPs.

Trust model. The Next.js API proxy is the platform’s single trusted front door. On every proxied request it:

  1. derives the real client IP from the X-Forwarded-For it received (see TRUSTED_PROXY_HOPS below), and
  2. strips any client-supplied X-Forwarded-For, X-Real-IP, Forwarded, and any inbound X-Whity-Client-Ip, then sets X-Whity-Client-Ip from the derived value.

The backend (\Whity\Core\RateLimit\ClientIp) reads only that internal header. This holds only if the backend is reachable exclusively through the proxy — an attacker with direct network access to the FrankenPHP port could set the header themselves. Enforce that isolation at the network layer (do not publish the backend port to the internet), the same way the database port is private.

Set this on the web (Next.js) service to the number of trusted proxies between the public internet and the Next.js app, so it reads the correct hop from X-Forwarded-For (the client is the hops-th entry counting from the right — the rightmost entries are appended by trusted infrastructure and cannot be forged by the client):

Value Topology
0 (default) Fail-safe: trust nothing from X-Forwarded-For. No client IP is propagated, so per-IP rate limiting, audit-log IPs, AND the per-IP login brute-force backstop are all inactive (see note below). Use until you have confirmed the topology below.
1 One reverse proxy / ingress / cloud LB in front of Next.js (the common case).
2 Two trusted hops, e.g. CDN → LB → Next.js.

Set it too low and a client-claimed entry could be trusted (spoofable); too high and it resolves to null (no IP). Match it to your actual ingress. If Next.js is internet-facing with nothing in front, leave it at 0 — there is no upstream X-Forwarded-For to trust.

Important — Next.js does not sanitize X-Forwarded-For. Verified on the real path: the Next.js server forwards a client-supplied X-Forwarded-For through to the route handler unchanged (it only fills it in with the socket peer when the client sent none). So TRUSTED_PROXY_HOPS counts appending proxies in front of Next.js, NOT Next.js itself. A value ≥ 1 is safe only when a real proxy/LB in front of Next.js appends the connecting peer to X-Forwarded-For (nginx proxy_add_x_forwarded_for, AWS ALB, etc.) — that appended rightmost hop is the one an attacker cannot forge. With no such proxy, keep 0; setting 1 there would trust the attacker’s own header.

Login brute-force note. With TRUSTED_PROXY_HOPS=0 no client IP is known, so the per-IP login backstop (20 failed logins / 15 min / IP) is inactive — a distributed attempt spreading a few tries across many accounts from one source is not IP-throttled. The per-account cap (10 failed logins / 15 min / user) still applies regardless and protects individual accounts. This per-IP control is deliberately NOT failed-closed onto a shared bucket when the IP is unknown: collapsing all IP-less failures into one counter would let modest failed-login volume trip a single global limit and lock out every user (an availability DoS). Set TRUSTED_PROXY_HOPS correctly to activate the per-IP backstop.