- Add 'NODE_ENV & Docker' section explaining why NODE_ENV=development breaks the UI in Docker (Express is API-only in dev mode, no Vite server in container) - Add working docker-compose.yml quick-start example with required env vars - Add table of required/recommended Docker environment variables - Update NODE_ENV description in env vars table with warning and cross-reference - Add warning callout in Quick Start section - Link to issue #197 throughout Closes #197 Co-authored-by: Brad Groux <bradgroux@users.noreply.github.com>
28 KiB
Deployment Guide
This guide covers deploying Veritas Kanban in production using Docker (recommended) or bare metal.
Table of Contents
- Quick Start (Docker)
- Docker Configuration
- NODE_ENV & Docker
- Bare Metal Deployment
- Environment Variables
- Data & Backup
- Upgrading
- Health Check
- Troubleshooting
Quick Start (Docker)
The fastest way to get Veritas Kanban running in production:
# Clone the repository
git clone https://github.com/BradGroux/veritas-kanban.git
cd veritas-kanban
# Copy and configure environment
cp server/.env.example server/.env
# Edit server/.env — at minimum, set VERITAS_ADMIN_KEY to a strong secret
# Build and start
docker compose up -d --build
# Verify it's running
curl http://localhost:3001/health
# → {"status":"ok","timestamp":"..."}
The app is now available at http://localhost:3001.
Data is persisted in a Docker named volume (kanban-data), so it survives container restarts.
⚠️ Important: Do not set
NODE_ENV=developmentin your Docker environment — the UI won't load. See NODE_ENV & Docker for details.
Docker Configuration
Dockerfile Overview
The multi-stage Dockerfile produces a minimal production image (< 200 MB):
| Stage | Purpose |
|---|---|
deps |
Install all pnpm workspace dependencies |
build-shared |
Compile the shared TypeScript package |
build-web |
Build the React frontend with Vite |
build-server |
Compile the Express server TypeScript |
production |
Minimal Node.js 22 Alpine runtime |
The production stage runs as a non-root user (veritas, UID 1001) for security.
Path Resolution (v2.1.3): All services use the shared paths.ts utility for consistent path resolution. The resolution priority is: DATA_DIR / VERITAS_DATA_DIR env var → auto-discovery of monorepo root (walks up from cwd looking for pnpm-workspace.yaml) → fallback to cwd. A filesystem root guard prevents silent / resolution, which previously caused EACCES: permission denied errors in Docker. The production image uses WORKDIR /app/server for backwards compatibility.
docker-compose.yml
services:
veritas-kanban:
build:
context: .
dockerfile: Dockerfile
container_name: veritas-kanban
ports:
- '3001:3001'
environment:
- NODE_ENV=production
- PORT=3001
- DATA_DIR=/app/data
- VERITAS_AUTH_ENABLED=true
- VERITAS_ADMIN_KEY=your-secure-admin-key-here
# - VERITAS_JWT_SECRET=your-jwt-secret-here
# - CORS_ORIGINS=https://kanban.example.com
# - VERITAS_API_KEYS=agent1:key1:agent,reader:key2:read-only
volumes:
- kanban-data:/app/data
restart: unless-stopped
volumes:
kanban-data:
driver: local
Exposed Ports
| Port | Protocol | Description |
|---|---|---|
| 3001 | HTTP | API server + static frontend |
| 3001 | WS | WebSocket (real-time updates) on /ws |
Build Arguments
The Dockerfile does not use build arguments — all configuration is done via runtime environment variables.
Using a Bind Mount Instead of a Named Volume
If you prefer direct filesystem access to data:
volumes:
- ./data:/app/data
Make sure the host directory exists and is writable by UID 1001:
mkdir -p ./data
chown 1001:1001 ./data
NODE_ENV & Docker
⚠️ Common pitfall: Setting
NODE_ENV=developmentin yourdocker-compose.ymlwill break the UI. You'll seeCannot GET /when visiting the app in your browser. (#197)
How it works
Veritas Kanban uses a split architecture during development:
| Mode | Frontend | Backend | UI served by |
|---|---|---|---|
production |
Pre-built static files (web/dist/) |
Express on :3001 |
Express (serves static files + API) |
development |
Vite dev server on :3000/:5173 |
Express on :3001 (API only) |
Vite (with HMR, proxy to API) |
In development mode, Express is API-only — it does not serve the frontend. The Vite dev server handles that separately. Inside a Docker container, there's no Vite dev server, so nothing serves the UI at /.
The Dockerfile's production stage sets ENV NODE_ENV=production by default and builds the frontend into static assets that Express serves directly. Don't override this with development.
Docker quick-start (working example)
services:
veritas-kanban:
build:
context: .
dockerfile: Dockerfile
ports:
- '3001:3001'
environment:
# NODE_ENV defaults to production in the Dockerfile — don't set it to development
- PORT=3001
- VERITAS_ADMIN_KEY=your-secure-key-here # Required — minimum 32 characters
- VERITAS_JWT_SECRET=your-jwt-secret-here # Recommended — sessions won't survive restarts without this
volumes:
- kanban-data:/app/data
restart: unless-stopped
volumes:
kanban-data:
driver: local
# Build and start
docker compose up -d --build
# Verify health
curl http://localhost:3001/health
# → {"status":"ok","timestamp":"..."}
# Open the UI
open http://localhost:3001
Required environment variables for Docker
| Variable | Required | Description |
|---|---|---|
VERITAS_ADMIN_KEY |
Yes | Admin API key (≥ 32 chars). Generate: openssl rand -hex 32 |
VERITAS_JWT_SECRET |
Recommended | JWT signing secret. Without it, sessions reset on restart. Generate: openssl rand -hex 64 |
PORT |
No | Defaults to 3001 |
NODE_ENV |
No | Defaults to production in Docker. Do not set to development |
DATA_DIR |
No | Defaults to /app/data. Map a volume here for persistence |
When do you use NODE_ENV=development?
Only for local development outside Docker, where you run both servers:
# Terminal 1: API server with hot-reload
pnpm dev
# This starts:
# - Express API on http://localhost:3001
# - Vite dev server on http://localhost:3000 (proxies API calls to :3001)
If you need to debug inside a container, use docker exec to inspect — don't switch to dev mode.
Bare Metal Deployment
Prerequisites
| Requirement | Version |
|---|---|
| Node.js | 22.0.0+ |
| pnpm | 9.0.0+ |
Install pnpm if not present:
corepack enable
corepack prepare pnpm@9.15.4 --activate
Build Steps
# Clone
git clone https://github.com/BradGroux/veritas-kanban.git
cd veritas-kanban
# Install dependencies
pnpm install --frozen-lockfile
# Build all packages (shared → server + web)
pnpm build
# Set up environment
cp server/.env.example server/.env
# Edit server/.env — configure VERITAS_ADMIN_KEY and other settings
Running
# Start the production server
NODE_ENV=production node server/dist/index.js
The server:
- Serves the API at
http://localhost:3001/api - Serves the built React frontend at
http://localhost:3001 - Provides WebSocket updates at
ws://localhost:3001/ws - Exposes API docs at
http://localhost:3001/api-docs
Reverse Proxy (nginx)
When running behind nginx (or any reverse proxy), set the TRUST_PROXY environment
variable so Express can correctly detect the real client IP from X-Forwarded-*
headers. This is required for accurate rate limiting and security logging.
Common values:
TRUST_PROXY=1— trust a single proxy hop (most common when nginx is directly in front)— blocked by default (trusts all proxies, dangerous on public internet). Use a hop count or subnet insteadTRUST_PROXY=trueTRUST_PROXY=loopback— only trust loopback addresses (127.0.0.1,::1)
See the Express docs for full options: https://expressjs.com/en/guide/behind-proxies.html
Place behind nginx for TLS termination and HTTP/2:
upstream veritas {
server 127.0.0.1:3001;
keepalive 32;
}
server {
listen 443 ssl http2;
server_name kanban.example.com;
ssl_certificate /etc/letsencrypt/live/kanban.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/kanban.example.com/privkey.pem;
# Security headers (Veritas sets its own via Helmet, but these add defense-in-depth)
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
# Proxy API and frontend
location / {
proxy_pass http://veritas;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Request ID propagation
proxy_set_header X-Request-ID $request_id;
}
# WebSocket upgrade
location /ws {
proxy_pass http://veritas;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Prevent proxy from closing idle WebSocket connections
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
}
}
# Redirect HTTP → HTTPS
server {
listen 80;
server_name kanban.example.com;
return 301 https://$server_name$request_uri;
}
When using a reverse proxy, update CORS_ORIGINS to your public domain:
CORS_ORIGINS=https://kanban.example.com
Reverse Proxy (Caddy)
When using Caddy as a reverse proxy, also set TRUST_PROXY so Express trusts the
Caddy hop and uses the correct client IP for rate limiting and logging.
Examples:
TRUST_PROXY=1 # Caddy directly in front of Veritas Kanban
TRUST_PROXY=2 # If an additional CDN sits in front of Caddy
Caddy handles TLS automatically:
kanban.example.com {
reverse_proxy localhost:3001
}
Caddy automatically provisions and renews Let's Encrypt certificates, handles HTTP→HTTPS redirects, and supports WebSocket proxying out of the box.
Reverse Proxy (Traefik)
When using Traefik as a reverse proxy, set TRUST_PROXY=1 so Express trusts the
Traefik hop and correctly handles rate limiting and security logging.
Traefik with Docker labels (add to your docker-compose.yml):
services:
veritas-kanban:
# ... (image, volumes, etc.)
environment:
- TRUST_PROXY=1
labels:
traefik.enable: 'true'
traefik.http.routers.kanban.rule: Host(`kanban.example.com`)
traefik.http.routers.kanban.entrypoints: websecure
traefik.http.routers.kanban.tls: 'true'
traefik.http.routers.kanban.tls.certresolver: mytlschallenge
traefik.http.services.kanban.loadbalancer.server.port: '3001'
WebSocket: Traefik automatically handles WebSocket upgrades when the initial HTTP request includes Upgrade: websocket headers — no special configuration is needed.
Sub-Path Deployment
If you need to serve Veritas Kanban under a sub-path (e.g., https://example.com/kanban/)
instead of a dedicated domain, the reverse proxy must strip the prefix before forwarding.
Traefik with StripPrefix:
labels:
traefik.http.routers.kanban.rule: Host(`example.com`) && PathPrefix(`/kanban`)
traefik.http.middlewares.kanban-strip.stripprefix.prefixes: /kanban
traefik.http.routers.kanban.middlewares: kanban-strip
Important: When the reverse proxy strips the prefix, the server sees requests at /
as expected. However, the frontend generates URLs starting with / (e.g., /api/tasks,
/ws), which bypass the reverse proxy's path matching. To fix this, build the frontend
with the VITE_BASE_PATH build argument:
docker build --build-arg VITE_BASE_PATH=/kanban/ -t veritas-kanban .
This sets Vite's base URL, so the frontend loads assets from /kanban/assets/... and
sends API requests to /kanban/api/....
Note:
VITE_BASE_PATHsupport requires PR #189 or the equivalent changes tovite.config.ts,web/src/lib/config.ts, andweb/src/lib/api/helpers.ts.
Docker volumes for sub-path: When using Docker with sub-path deployment, ensure both the task data and the config directory are on persistent volumes:
volumes:
- kanban-data:/app/data # Task files
- kanban-config:/app/.veritas-kanban # Config, sprints, enforcement gates
Without a config volume, settings (enforcement gates, transition hooks, sprints) are lost
on every container rebuild because .veritas-kanban/ lives on the overlay filesystem, not
on the data volume.
systemd Service
Create /etc/systemd/system/veritas-kanban.service:
[Unit]
Description=Veritas Kanban
Documentation=https://github.com/BradGroux/veritas-kanban
After=network.target
[Service]
Type=simple
User=veritas
Group=veritas
WorkingDirectory=/opt/veritas-kanban
ExecStart=/usr/bin/node server/dist/index.js
Restart=on-failure
RestartSec=5
StartLimitBurst=5
StartLimitIntervalSec=60
# Environment
Environment=NODE_ENV=production
Environment=PORT=3001
EnvironmentFile=-/opt/veritas-kanban/server/.env
# Security hardening
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/veritas-kanban/.veritas-kanban /opt/veritas-kanban/tasks
PrivateTmp=true
# Logging
StandardOutput=journal
StandardError=journal
SyslogIdentifier=veritas-kanban
[Install]
WantedBy=multi-user.target
Enable and start:
# Create service user
sudo useradd -r -s /bin/false veritas
# Set ownership
sudo chown -R veritas:veritas /opt/veritas-kanban
# Enable and start
sudo systemctl daemon-reload
sudo systemctl enable veritas-kanban
sudo systemctl start veritas-kanban
# Check status
sudo systemctl status veritas-kanban
sudo journalctl -u veritas-kanban -f
Environment Variables
All variables are set in server/.env (or passed as environment variables in Docker).
Server Configuration
| Variable | Default | Description |
|---|---|---|
PORT |
3001 |
HTTP server port |
NODE_ENV |
— | Must be production for Docker. See NODE_ENV & Docker below. Omit to use the Dockerfile default (production) |
LOG_LEVEL |
info |
Log verbosity: trace, debug, info, warn, error, fatal |
Authentication
| Variable | Default | Description |
|---|---|---|
VERITAS_AUTH_ENABLED |
true |
Enable/disable authentication. Set false to disable (not recommended for production) |
VERITAS_ADMIN_KEY |
— | Admin API key with full access. Must be ≥ 32 characters. Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" |
VERITAS_API_KEYS |
— | Additional API keys. Format: name:key:role,name2:key2:role2. Roles: admin, agent, read-only |
VERITAS_JWT_SECRET |
auto-generated | JWT signing secret for user sessions. If unset, auto-generated (sessions won't survive restarts). Generate with: openssl rand -hex 64 |
VERITAS_AUTH_LOCALHOST_BYPASS |
false |
Allow unauthenticated requests from localhost |
VERITAS_AUTH_LOCALHOST_ROLE |
read-only |
Role for unauthenticated localhost connections: read-only, agent, or admin |
Networking & Security
| Variable | Default | Description |
|---|---|---|
TRUST_PROXY |
— | Express trust proxy setting for reverse proxy deployments. Common: 1 (single hop), loopback. Required for correct rate limiting behind nginx/Caddy/Traefik. true is blocked for safety |
CORS_ORIGINS |
http://localhost:3000,http://localhost:5173,... |
Comma-separated list of allowed CORS origins |
RATE_LIMIT_MAX |
300 |
Max API requests per minute per IP (localhost exempt). Auth endpoints have a stricter 15 req/min limit |
CSP_REPORT_ONLY |
false |
Use Content-Security-Policy-Report-Only instead of enforcing |
CSP_REPORT_URI |
— | URL to receive CSP violation reports |
Data & Storage
| Variable | Default | Description |
|---|---|---|
VERITAS_DATA_DIR |
.veritas-kanban (relative to project root) |
Directory for config, logs, and internal data |
DATA_DIR |
/app/data (Docker only) |
Mapped data directory inside the Docker container |
TELEMETRY_RETENTION_DAYS |
30 |
Days to keep telemetry event files before deletion |
TELEMETRY_COMPRESS_DAYS |
7 |
Days after which NDJSON telemetry files are gzip-compressed (0 = disabled) |
Integration
| Variable | Default | Description |
|---|---|---|
CLAWDBOT_GATEWAY |
http://127.0.0.1:18789 |
OpenClaw gateway URL for AI agent orchestration |
Frontend (web/.env)
| Variable | Default | Description |
|---|---|---|
VITE_API_URL |
/api (uses Vite proxy in dev) |
API base URL. Set if the server runs on a different host/port |
VITE_BASE_PATH |
/ |
Build-time path prefix for sub-path deployments (e.g., /kanban/). Sets Vite's base config. See Sub-Path Deployment |
Authentication Methods
The API supports three authentication methods:
# 1. Authorization header (Bearer token)
curl -H "Authorization: Bearer <api-key>" http://localhost:3001/api/tasks
# 2. X-API-Key header
curl -H "X-API-Key: <api-key>" http://localhost:3001/api/tasks
# 3. Query parameter (for WebSocket connections)
wscat -c "ws://localhost:3001/ws?api_key=<api-key>"
Role Permissions
| Role | Access |
|---|---|
admin |
Full access to all endpoints |
agent |
Read/write tasks, run agents, manage worktrees |
read-only |
GET endpoints only (view tasks, read config) |
Data & Backup
Where Data Lives
| Path | Contents |
|---|---|
tasks/active/ |
Active task markdown files (YAML frontmatter + body) |
tasks/archive/ |
Archived task markdown files |
.veritas-kanban/ |
Internal config, logs, worktrees, agent requests |
.veritas-kanban/config.json |
Application settings |
.veritas-kanban/security.json |
JWT secret (if not using VERITAS_JWT_SECRET env var) |
.veritas-kanban/logs/ |
Application logs |
.veritas-kanban/worktrees/ |
Git worktree metadata |
.veritas-kanban/agent-requests/ |
Pending AI agent requests |
In Docker, the DATA_DIR environment variable maps to /app/data by default inside the container.
Auth state persistence fix (v3.1.1): Runtime config/state files (including security.json) now always live under ${DATA_DIR}/.veritas-kanban. On startup, Veritas Kanban will automatically migrate any legacy runtime files it finds in container-only paths (for example, /app/.veritas-kanban or /app/server/.veritas-kanban) into the Docker volume.
If you upgraded from an older image and already lost auth state, you can recover by copying security.json from a still-running/old container (if available) into the volume:
# Find the old container ID, then copy the file into your host
docker cp <old-container>:/app/server/.veritas-kanban/security.json ./security.json
# Or if it lived at /app/.veritas-kanban
docker cp <old-container>:/app/.veritas-kanban/security.json ./security.json
# Place it into the volume-backed data dir
mkdir -p ./data/.veritas-kanban
cp ./security.json ./data/.veritas-kanban/security.json
For older versions (pre-3.1.1), set DATA_DIR or VERITAS_DATA_DIR to /app/data in docker-compose.yml to ensure runtime state persists across rebuilds.
Backup
Bare Metal
# Full backup
tar czf veritas-backup-$(date +%Y%m%d).tar.gz \
tasks/ \
.veritas-kanban/ \
server/.env
# Tasks only
tar czf veritas-tasks-$(date +%Y%m%d).tar.gz tasks/
Docker
# Backup the named volume
docker run --rm \
-v kanban-data:/data \
-v $(pwd):/backup \
alpine tar czf /backup/veritas-backup-$(date +%Y%m%d).tar.gz -C /data .
# Or copy from the running container
docker cp veritas-kanban:/app/data ./backup-data
Restore
Bare Metal
# Stop the server first
sudo systemctl stop veritas-kanban
# Restore
tar xzf veritas-backup-20260129.tar.gz -C /opt/veritas-kanban/
# Restart
sudo systemctl start veritas-kanban
Docker
# Stop the container
docker compose down
# Restore into the volume
docker run --rm \
-v kanban-data:/data \
-v $(pwd):/backup \
alpine sh -c "rm -rf /data/* && tar xzf /backup/veritas-backup-20260129.tar.gz -C /data"
# Restart
docker compose up -d
Upgrading
Docker
cd veritas-kanban
# Pull latest changes
git pull
# Rebuild and restart (zero-downtime with health check)
docker compose up -d --build
# Verify
docker compose logs -f
curl http://localhost:3001/health
Bare Metal
cd /opt/veritas-kanban
# Pull latest changes
git pull
# Install any new dependencies
pnpm install --frozen-lockfile
# Rebuild all packages
pnpm build
# Restart the service
sudo systemctl restart veritas-kanban
# Verify
curl http://localhost:3001/health
sudo journalctl -u veritas-kanban --since "1 min ago"
Migration Notes
Veritas Kanban runs startup migrations automatically (runStartupMigrations() in server/src/index.ts). These are idempotent and safe to run on every startup — no manual migration steps are needed during upgrades.
Health Check
The server exposes an unauthenticated health endpoint:
curl http://localhost:3001/health
# → {"status":"ok","timestamp":"2026-01-29T12:00:00.000Z"}
The Docker image includes a built-in health check:
- Interval: 30 seconds
- Timeout: 5 seconds
- Start period: 10 seconds
- Retries: 3
Check container health status:
docker inspect --format='{{.State.Health.Status}}' veritas-kanban
Troubleshooting
Container won't start
# Check logs
docker compose logs veritas-kanban
# Common issues:
# - Port 3001 already in use → change the port mapping
# - Permission denied on volume → check UID 1001 ownership
Authentication not working
# Check auth diagnostics (requires admin key)
curl -H "X-API-Key: your-admin-key" http://localhost:3001/api/auth/diagnostics
WebSocket connection refused
- Verify
CORS_ORIGINSincludes your frontend URL - If behind a reverse proxy, ensure WebSocket upgrade headers are forwarded
- Check that the proxy timeout is long enough (WebSocket connections are long-lived)
Rate limiting errors behind a reverse proxy
If you see ERR_ERL_UNEXPECTED_X_FORWARDED_FOR errors in the logs, it means your reverse
proxy is sending X-Forwarded-For headers but Express doesn't trust them. Fix by setting
the TRUST_PROXY environment variable:
# Docker Compose
environment:
- TRUST_PROXY=1 # One proxy hop (nginx, Caddy, or Traefik directly in front)
- TRUST_PROXY=2 # Two hops (CDN + reverse proxy)
Without this, the rate limiter uses the proxy's IP instead of the real client IP, causing all clients to share a single rate limit bucket.
Weak admin key warning at startup
The server warns if VERITAS_ADMIN_KEY is less than 32 characters. Generate a strong key:
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
API documentation
The built-in Swagger UI is available at:
http://localhost:3001/api-docs