This guide walks you through every self-hosting scenario — from running locally for personal use to a production deployment behind a reverse proxy or on Tailscale.
Credit: This guide was originally contributed by @xechehot in PR #126. It has been expanded here to cover additional deployment patterns.
Remote security posture: v5 remote access is designed around a trusted same-origin host for the web client,
/api, and/ws. Before exposing Veritas beyond loopback, read ADR 0002: v5 Remote Server Mode and Network Security Posture.
- Prerequisites
- Build Steps
- Local Hosting
- LAN Access
- Tailscale Serve
- Reverse Proxy
- Docker
- Security
- Environment Variables Reference
- Troubleshooting
| Requirement | Version | Install |
|---|---|---|
| Node.js | 22.0.0+ | https://nodejs.org or nvm install 22 |
| pnpm | 11.1.1+ | corepack enable && corepack prepare pnpm@11.1.1 --activate |
| Git | any | https://git-scm.com |
Verify:
node --version # v22.x.x
pnpm --version # 11.x.x# 1. Clone the repository
git clone https://github.com/BradGroux/veritas-kanban.git
cd veritas-kanban
# 2. Install all workspace dependencies
pnpm install --frozen-lockfile
# 3. Build all packages (shared → server + web)
pnpm build
# 4. Configure environment
cp server/.env.example server/.env
# Edit server/.env — at minimum set VERITAS_ADMIN_KEYThe build produces:
| Path | Contents |
|---|---|
shared/dist/ |
Shared TypeScript types and utilities |
server/dist/ |
Compiled Express API server |
web/dist/ |
Static React frontend (served by Express in production) |
Run Veritas Kanban on your own machine for personal use.
# Start the production server
NODE_ENV=production node server/dist/index.jsThe app is now available at http://localhost:3001.
- API:
http://localhost:3001/api - UI:
http://localhost:3001 - WebSocket:
ws://localhost:3001/ws - API Docs (Swagger):
http://localhost:3001/api-docs
The example env file keeps VERITAS_AUTH_LOCALHOST_BYPASS=true for local
development convenience, so unauthenticated loopback requests can use the
configured local role outside production. In NODE_ENV=production, the server
does not honor localhost bypass for HTTP or WebSocket auth. Set
VERITAS_AUTH_LOCALHOST_ROLE=admin only for trusted local development when you
want full access without a key on your own machine.
For active development with hot-module replacement:
pnpm dev
# Vite dev server → http://localhost:3000 (proxies API to :3001)
# Express API server → http://localhost:3001Note:
NODE_ENV=developmentis for local development only. Never use it in Docker — the Express server does not serve the frontend in development mode.
Serve Veritas Kanban to other devices on your local network (phones, other laptops, tablets).
LAN access is server mode, not localhost mode. Keep VERITAS_AUTH_ENABLED=true,
set VERITAS_AUTH_LOCALHOST_BYPASS=false, and use HTTPS, VPN, or a trusted
tunnel for browser/mobile sessions unless the network is strictly private and
temporary.
Unauthenticated /metrics scrapes are allowed only when
PROMETHEUS_METRICS_PUBLIC=true; otherwise use PROMETHEUS_METRICS_TOKEN or
an API key with telemetry read access.
By default the server listens on 127.0.0.1. To accept connections from other devices, set HOST=0.0.0.0:
HOST=0.0.0.0 NODE_ENV=production node server/dist/index.jsOr add to server/.env:
HOST=0.0.0.0If you're running the Vite dev server in development mode, set VITE_ALLOWED_HOSTS so Vite accepts requests from your LAN IP:
# server/.env or export before running pnpm dev
VITE_ALLOWED_HOSTS=192.168.1.100,my-machine.local
# Or allow all hosts (development only — never in production):
VITE_ALLOWED_HOSTS=*This controls vite.config.ts's server.allowedHosts setting.
Add your LAN IP or hostname to CORS_ORIGINS in server/.env:
CORS_ORIGINS=http://localhost:3000,http://localhost:5173,http://192.168.1.100:3001# macOS / Linux
ip route get 1 | awk '{print $7; exit}'
# or
hostname -I | awk '{print $1}'
# macOS
ipconfig getifaddr en0Your LAN URL will be http://<your-ip>:3001.
Tailscale Serve lets you expose Veritas Kanban securely to your tailnet (all your devices) without opening firewall ports. This was the primary use case from @xechehot's original PR #126.
Treat the Tailscale URL as a remote origin. Do not rely on localhost bypass for
tailnet clients, and verify /api, /ws, /health/ready, and
/api/auth/status through the served HTTPS URL.
Expose the app at https://<your-machine>.ts.net/:
# Start Veritas Kanban
NODE_ENV=production node server/dist/index.js
# Expose via Tailscale Serve (proxies HTTPS → localhost:3001)
tailscale serve https / http://localhost:3001Access from any tailnet device at https://<your-machine>.ts.net.
If you want to share port 443 with other services and serve Veritas Kanban under /kanban/:
The frontend must be built with VITE_BASE_PATH=/kanban/ so all asset URLs, API calls, and client-side routes use the correct prefix:
VITE_BASE_PATH=/kanban/ pnpm --filter @veritas-kanban/web build
# Then rebuild the server (if needed)
pnpm --filter @veritas-kanban/server buildOr with Docker:
docker build --build-arg VITE_BASE_PATH=/kanban/ -t veritas-kanban .NODE_ENV=production node server/dist/index.js# Route /kanban/ traffic to localhost:3001
tailscale serve https /kanban/ http://localhost:3001
# Verify the serve config
tailscale serve statusCORS_ORIGINS=https://<your-machine>.ts.netAccess the app at https://<your-machine>.ts.net/kanban/.
To expose beyond your tailnet (public internet):
tailscale funnel 443 onSecurity: Funnel makes your instance publicly accessible. Ensure
VERITAS_AUTH_ENABLED=trueand use a strongVERITAS_ADMIN_KEYbefore enabling Funnel.
For production deployments with TLS, use a reverse proxy in front of Veritas Kanban. Always set TRUST_PROXY when behind a proxy.
The supported reverse-proxy shape is same-origin: the proxy serves the app shell,
API, WebSocket, health endpoints, and future PWA assets from the same public
origin. Split-origin setups require exact CORS_ORIGINS, WebSocket upgrade
testing, and explicit token handling as described in ADR 0002.
# server/.env
TRUST_PROXY=1
CORS_ORIGINS=https://kanban.example.comupstream 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;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
# Proxy everything to Veritas Kanban
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;
}
# WebSocket upgrade (real-time updates)
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;
# Keep WebSocket connections alive
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
}
}
# HTTP → HTTPS redirect
server {
listen 80;
server_name kanban.example.com;
return 301 https://$server_name$request_uri;
}Reload nginx after editing:
sudo nginx -t && sudo systemctl reload nginxCaddy handles TLS automatically (no certificate config needed):
kanban.example.com {
reverse_proxy localhost:3001
}# server/.env
TRUST_PROXY=1
CORS_ORIGINS=https://kanban.example.comCaddy handles WebSocket proxying and HTTP→HTTPS redirects automatically.
To serve under /kanban/ on a shared domain, build with VITE_BASE_PATH and strip the prefix in nginx:
VITE_BASE_PATH=/kanban/ pnpm --filter @veritas-kanban/web build
pnpm --filter @veritas-kanban/server buildlocation /kanban/ {
proxy_pass http://127.0.0.1:3001/; # trailing slash strips the prefix
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;
}
location /kanban/ws {
proxy_pass http://127.0.0.1:3001/ws;
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;
proxy_read_timeout 86400s;
}Docker is the recommended approach for production deployments.
Do not use the demo Compose files (docker-compose-demo.yml or demo/docker-compose.demo.yml) for production or shared-network deployments. They are local demo configs and may disable auth for convenience. Use the authenticated production compose example below and generate fresh secrets.
# Clone and configure
git clone https://github.com/BradGroux/veritas-kanban.git
cd veritas-kanban
cp server/.env.example server/.env
# Edit server/.env — set VERITAS_ADMIN_KEY to a strong secret (≥ 32 chars)
# Build and start
docker compose up -d --build
# Verify
curl http://localhost:3001/health
# → {"status":"ok","timestamp":"..."}The app is available at http://localhost:3001. Data persists in a named Docker volume (kanban-data).
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_ADMIN_KEY=your-secure-admin-key-here # ≥ 32 chars
- VERITAS_JWT_SECRET=your-jwt-secret-here # prevents session resets on restart
# - CORS_ORIGINS=https://kanban.example.com
# - TRUST_PROXY=1 # if behind nginx/Caddy/Traefik
# - VERITAS_API_KEYS=agent1:key1:agent,readonly:key2:read-only
volumes:
- kanban-data:/app/data
restart: unless-stopped
healthcheck:
test: ['CMD', 'wget', '--no-verbose', '--tries=1', '--spider', 'http://localhost:3001/health']
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
volumes:
kanban-data:
driver: localdocker build --build-arg VITE_BASE_PATH=/kanban/ -t veritas-kanban .docker compose up -d --build # Build and start in background
docker compose logs -f # Follow logs
docker compose down # Stop and remove containers
docker compose pull # Pull latest image (if using a registry)
# Inspect health status
docker inspect --format='{{.State.Health.Status}}' veritas-kanbanThe DATA_DIR=/app/data volume holds all persistent data:
/app/data/
├── tasks/
│ ├── active/ # Active task markdown files
│ └── archive/ # Archived tasks
└── .veritas-kanban/
├── config.json # App settings
├── security.json # JWT secret (if VERITAS_JWT_SECRET not set)
└── logs/ # Application logs
Without a named volume, data is lost on every docker compose down. Always use a volume or bind mount.
# Admin key (≥ 32 chars)
openssl rand -hex 32
# JWT secret
openssl rand -hex 64VERITAS_AUTH_ENABLED=true
VERITAS_ADMIN_KEY=<output of openssl rand -hex 32>
VERITAS_JWT_SECRET=<output of openssl rand -hex 64>
VERITAS_AUTH_LOCALHOST_BYPASS=falseWhen exposing Veritas beyond loopback, also use NODE_ENV=production and keep
the web client, /api, /ws, health endpoints, PWA assets, and service-worker
scope on one trusted origin whenever possible. Split-origin deployments must set
exact CORS_ORIGINS entries and confirm WebSocket origin/upgrade behavior.
Prometheus /metrics fails closed outside explicit loopback development binds;
use PROMETHEUS_METRICS_TOKEN or PROMETHEUS_METRICS_PUBLIC=true for external
scrapers.
Mobile install steps and offline-shell behavior are documented in
PWA install.
Grant agents scoped access without giving them the admin key:
# Format: name:key:role,...
# Roles: admin | agent | read-only
VERITAS_API_KEYS=my-agent:vk_abc123:agent,dashboard:vk_xyz456:read-onlyRole 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) |
# Bearer token
curl -H "Authorization: Bearer <api-key>" http://localhost:3001/api/tasks
# X-API-Key header
curl -H "X-API-Key: <api-key>" http://localhost:3001/api/tasks
# Query parameter (WebSocket)
wscat -c "ws://localhost:3001/ws?api_key=<api-key>"HTTP requests must pass API keys in Authorization: Bearer <key> or
X-API-Key. Do not put long-lived credentials in HTTP query strings, bookmarks,
QR codes, screenshots, reverse-proxy logs, or shareable URLs. The WebSocket
api_key query fallback exists for clients that cannot set headers during the
upgrade.
Always set TRUST_PROXY=1 when running behind a reverse proxy. Without it, Express uses the proxy's IP for rate limiting instead of the real client IP, so all users share one rate limit bucket.
TRUST_PROXY=1 # One proxy hop (nginx, Caddy directly in front)
TRUST_PROXY=2 # Two hops (CDN + reverse proxy)
TRUST_PROXY=loopback # Only trust loopback (127.0.0.1, ::1)Warning:
TRUST_PROXY=trueis blocked by default — it trusts all proxies and is unsafe on the public internet. Use a hop count or subnet instead.
All variables live in server/.env (copy from server/.env.example).
| Variable | Default | Description |
|---|---|---|
PORT |
3001 |
HTTP server port |
HOST |
127.0.0.1 |
Bind address. Set 0.0.0.0 for LAN/container access |
NODE_ENV |
— | production for production. Never development in Docker |
LOG_LEVEL |
info |
trace / debug / info / warn / error / fatal |
| Variable | Default | Description |
|---|---|---|
VERITAS_AUTH_ENABLED |
true |
Enable authentication. Set false only for trusted local use |
VERITAS_ADMIN_KEY |
— | Admin API key. Must be ≥ 32 chars. Required for production |
VERITAS_API_KEYS |
— | Additional keys. Format: name:key:role,name2:key2:role2 |
VERITAS_JWT_SECRET |
auto-gen | JWT signing secret. Unset = auto-generated (sessions reset on restart) |
VERITAS_AUTH_LOCALHOST_BYPASS |
false |
Allow unauthenticated localhost requests |
VERITAS_AUTH_LOCALHOST_ROLE |
read-only |
Role for localhost bypass: read-only, agent, or admin |
| Variable | Default | Description |
|---|---|---|
CORS_ORIGINS |
http://localhost:3000,... |
Comma-separated allowed CORS origins |
TRUST_PROXY |
— | Express proxy trust. Use 1 for single-hop (nginx/Caddy). true is blocked |
RATE_LIMIT_MAX |
300 |
Max API requests/minute/IP (localhost exempt) |
GET /metrics is public only in local development. In production, scrape it with one of these explicit configurations:
scrape_configs:
- job_name: veritas-kanban
metrics_path: /metrics
static_configs:
- targets: ['veritas.example.com']
bearer_token: '<PROMETHEUS_METRICS_TOKEN>'Set PROMETHEUS_METRICS_TOKEN on the Veritas server to the same secret, or use a normal Veritas API key with telemetry:read permission in the Authorization: Bearer <key> header. Only set PROMETHEUS_METRICS_PUBLIC=true for trusted private networks where unauthenticated metrics are intentional.
| Variable | Default | Description |
|---|---|---|
VERITAS_DATA_DIR |
.veritas-kanban |
Config, logs, internal state (relative to project root) |
DATA_DIR |
/app/data (Docker) |
Mapped data dir inside Docker container |
TELEMETRY_RETENTION_DAYS |
30 |
Days to keep telemetry event files |
TELEMETRY_COMPRESS_DAYS |
7 |
Days after which telemetry files are gzip-compressed |
| Variable | Default | Description |
|---|---|---|
VITE_BASE_PATH |
/ |
Sub-path prefix for the frontend (e.g., /kanban/). Set at build time, not runtime |
VITE_ALLOWED_HOSTS |
— | Comma-separated hostnames allowed by Vite dev server (dev only). * allows all |
| Variable | Default | Description |
|---|---|---|
CLAWDBOT_GATEWAY |
http://127.0.0.1:18789 |
OpenClaw gateway URL for AI agent orchestration |
VERITAS_WEBHOOK_URL |
— | Push task/chat events to an external service |
VERITAS_WEBHOOK_SECRET |
— | HMAC-SHA256 secret for webhook payload signing |
Cause: NODE_ENV=development is set in Docker. In dev mode, Express is API-only — it does not serve the frontend.
Fix: Remove NODE_ENV=development from your Docker environment. The Dockerfile defaults to production.
Cause: Your frontend origin is not in CORS_ORIGINS.
Fix: Add the exact origin (scheme + hostname + port) to CORS_ORIGINS:
CORS_ORIGINS=https://kanban.example.com,http://192.168.1.100:3001No trailing slashes. The origin must match exactly what the browser sends in the Origin header.
- Check that your reverse proxy forwards WebSocket upgrade headers:
- nginx: Needs
proxy_set_header Upgrade $http_upgrade; Connection "upgrade";in the/wslocation block. - Caddy: Handles WebSocket automatically — no config needed.
- nginx: Needs
- Check proxy timeout: WebSocket connections are long-lived. Set
proxy_read_timeout 86400sin nginx. - Verify
CORS_ORIGINSincludes the WebSocket origin.
Cause: Frontend was built without VITE_BASE_PATH.
Fix: Rebuild with the correct base path:
VITE_BASE_PATH=/kanban/ pnpm --filter @veritas-kanban/web buildOr with Docker:
docker build --build-arg VITE_BASE_PATH=/kanban/ -t veritas-kanban .Cause: Your reverse proxy sends X-Forwarded-For but TRUST_PROXY is not set.
Fix:
TRUST_PROXY=1Cause: VITE_BASE_PATH not set when using Tailscale Serve with sub-path routing.
Fix: Rebuild with VITE_BASE_PATH=/kanban/ (see Tailscale Serve above).
The server rejects or warns on VERITAS_ADMIN_KEY shorter than 32 characters. Generate a proper key:
openssl rand -hex 32Cause: VERITAS_JWT_SECRET is not set, so a new secret is generated each startup.
Fix: Set a persistent JWT secret:
openssl rand -hex 64
# Add to docker-compose.yml environment or server/.env
VERITAS_JWT_SECRET=<output>curl http://localhost:3001/health
# → {"status":"ok","timestamp":"..."}Auth diagnostics (requires admin key):
curl -H "X-API-Key: your-admin-key" http://localhost:3001/api/auth/diagnosticsFor general deployment (Docker, bare metal, systemd, reverse proxy) see also docs/DEPLOYMENT.md.