# Self-Hosting Veritas Kanban

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](https://github.com/xechehot) in [PR #126](https://github.com/BradGroux/veritas-kanban/pull/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](../architecture/ADR-0002-v5-remote-server-security-posture.md).

---

## Table of Contents

- [Prerequisites](#prerequisites)
- [Build Steps](#build-steps)
- [Local Hosting](#local-hosting)
- [LAN Access](#lan-access)
- [Tailscale Serve](#tailscale-serve)
- [Reverse Proxy](#reverse-proxy)
  - [nginx](#nginx)
  - [Caddy](#caddy)
- [Docker](#docker)
- [Security](#security)
- [Environment Variables Reference](#environment-variables-reference)
- [Troubleshooting](#troubleshooting)

---

## Prerequisites

| Requirement | Version | Install                                                      |
| ----------- | ------- | ------------------------------------------------------------ |
| Node.js     | 22.22.1+ | https://nodejs.org or `nvm install 22`                       |
| pnpm        | 11.1.1   | `corepack enable && corepack prepare pnpm@11.1.1 --activate` |
| Git         | 2.38+    | https://git-scm.com                                          |

Verify:

```bash
node --version   # v22.22.1 or newer
pnpm --version   # 11.1.1
```

---

## Build Steps

```bash
# 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_KEY
```

The 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) |

---

## Local Hosting

Run Veritas Kanban on your own machine for personal use.

```bash
# Start the production server
NODE_ENV=production node server/dist/index.js
```

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

### Development mode

For active development with hot-module replacement:

```bash
pnpm dev
# Vite dev server → http://localhost:3000 (proxies API to :3001)
# Express API server → http://localhost:3001
```

> **Note:** `NODE_ENV=development` is for local development only. Never use it in Docker — the Express server does not serve the frontend in development mode.

---

## LAN Access

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.

### 1. Bind to all interfaces

By default the server listens on `127.0.0.1`. To accept connections from other devices, set `HOST=0.0.0.0`:

```bash
HOST=0.0.0.0 NODE_ENV=production node server/dist/index.js
```

Or add to `server/.env`:

```env
HOST=0.0.0.0
```

### 2. Allow Vite dev server (development only)

If you're running the Vite dev server in development mode, set `VITE_ALLOWED_HOSTS` so Vite accepts requests from your LAN IP:

```env
# 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.

### 3. Update CORS

Add your LAN IP or hostname to `CORS_ORIGINS` in `server/.env`:

```env
CORS_ORIGINS=http://localhost:3000,http://localhost:5173,http://192.168.1.100:3001
```

### 4. Find your LAN IP

```bash
# macOS / Linux
ip route get 1 | awk '{print $7; exit}'
# or
hostname -I | awk '{print $1}'

# macOS
ipconfig getifaddr en0
```

Your LAN URL will be `http://<your-ip>:3001`.

---

## Tailscale Serve

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](https://github.com/BradGroux/veritas-kanban/pull/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.

### Option A: Root path (simplest)

Expose the app at `https://<your-machine>.ts.net/`:

```bash
# Start Veritas Kanban
NODE_ENV=production node server/dist/index.js

# Expose via Tailscale Serve (proxies HTTPS → localhost:3001)
tailscale serve https / http://localhost:3001
```

Access from any tailnet device at `https://<your-machine>.ts.net`.

### Option B: Sub-path routing (`/kanban/`)

If you want to share port 443 with other services and serve Veritas Kanban under `/kanban/`:

#### Step 1 — Build the frontend with the base path

The frontend must be built with `VITE_BASE_PATH=/kanban/` so all asset URLs, API calls, and client-side routes use the correct prefix:

```bash
VITE_BASE_PATH=/kanban/ pnpm --filter @veritas-kanban/web build
# Then rebuild the server (if needed)
pnpm --filter @veritas-kanban/server build
```

Or with Docker:

```bash
docker build --build-arg VITE_BASE_PATH=/kanban/ -t veritas-kanban .
```

#### Step 2 — Start the server

```bash
NODE_ENV=production node server/dist/index.js
```

#### Step 3 — Configure Tailscale Serve

```bash
# Route /kanban/ traffic to localhost:3001
tailscale serve https /kanban/ http://localhost:3001

# Verify the serve config
tailscale serve status
```

#### Step 4 — Update CORS

```env
CORS_ORIGINS=https://<your-machine>.ts.net
```

Access the app at `https://<your-machine>.ts.net/kanban/`.

### Tailscale Funnel (public internet access)

To expose beyond your tailnet (public internet):

```bash
tailscale funnel 443 on
```

> **Security:** Funnel makes your instance publicly accessible. Ensure `VERITAS_AUTH_ENABLED=true` and use a strong `VERITAS_ADMIN_KEY` before enabling Funnel.

---

## Reverse Proxy

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.

### nginx

```env
# server/.env
TRUST_PROXY=1
CORS_ORIGINS=https://kanban.example.com
```

```nginx
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;

    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:

```bash
sudo nginx -t && sudo systemctl reload nginx
```

### Caddy

Caddy handles TLS automatically (no certificate config needed):

```caddyfile
kanban.example.com {
    reverse_proxy localhost:3001
}
```

```env
# server/.env
TRUST_PROXY=1
CORS_ORIGINS=https://kanban.example.com
```

Caddy handles WebSocket proxying and HTTP→HTTPS redirects automatically.

### Sub-path with nginx

To serve under `/kanban/` on a shared domain, build with `VITE_BASE_PATH` and strip the prefix in nginx:

```bash
VITE_BASE_PATH=/kanban/ pnpm --filter @veritas-kanban/web build
pnpm --filter @veritas-kanban/server build
```

```nginx
location /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

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.

### Quick start

```bash
# 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`).

### docker-compose.yml

```yaml
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: local
```

### Building with a sub-path

```bash
docker build --build-arg VITE_BASE_PATH=/kanban/ -t veritas-kanban .
```

### Common Docker commands

```bash
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-kanban
```

### Data persistence

The `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
```

This tree is illustrative, not exhaustive. Veritas also stores workflows,
runtime evidence, telemetry, provider records, and other governed domains under
the same canonical root. Back up the entire stopped-writer volume.

**Without a named volume, data is lost on every `docker compose down`.** Always use a volume or bind mount.

For `VERITAS_STORAGE=sqlite`, persistence is not enough: the authoritative
database must resolve to a verified durable local filesystem. Detected NFS,
SMB/CIFS, FUSE, WebDAV, NAS, volatile filesystems, container overlay, unknown
probes, and database-file symlinks refuse startup. Synchronized cloud folders
remain unsupported but may look local to the operating system, so keep them out
of the authoritative path. Copy only completed Maintenance exports/backups to
remote storage.
See [SQLite Filesystem Safety Posture](../SQLITE-SCHEMA.md#filesystem-safety-posture).

---

## Security

### Generate strong keys

```bash
# Admin key (≥ 32 chars)
openssl rand -hex 32

# JWT secret
openssl rand -hex 64
```

### Minimum production config

```env
VERITAS_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=false
```

When 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](PWA_INSTALL.md).

### API keys for agents

Grant agents scoped access without giving them the admin key:

```env
# Format: name:key:role,...
# Roles: admin | agent | read-only
VERITAS_API_KEYS=my-agent:vk_abc123:agent,dashboard:vk_xyz456:read-only
```

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)   |

### Authentication methods

```bash
# 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.

### TRUST_PROXY

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.

```env
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=true` is blocked by default — it trusts all proxies and is unsafe on the public internet. Use a hop count or subnet instead.

---

## Environment Variables Reference

All variables live in `server/.env` (copy from `server/.env.example`).

### Server

| 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`        |

### Authentication

| 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`            |

### Networking

| 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)                                |

### Prometheus metrics

`GET /metrics` is public only in local development. In production, scrape it with one of these explicit configurations:

```yaml
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.

### Data & Storage

| Variable                   | Default              | Description                                             |
| -------------------------- | -------------------- | ------------------------------------------------------- |
| `VERITAS_DATA_DIR`         | Project root         | Storage root used when `DATA_DIR` is unset              |
| `DATA_DIR`                 | `/app/data` (Docker) | Preferred storage root; takes precedence                |
| `TELEMETRY_RETENTION_DAYS` | `30`                 | Days to keep telemetry event files                      |
| `TELEMETRY_COMPRESS_DAYS`  | `7`                  | Days after which telemetry files are gzip-compressed    |

### Frontend (build-time)

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

### Integration

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

---

## Troubleshooting

### Cannot GET / (UI not loading)

**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`.

---

### CORS error in browser console

**Cause:** Your frontend origin is not in `CORS_ORIGINS`.

**Fix:** Add the exact origin (scheme + hostname + port) to `CORS_ORIGINS`:

```env
CORS_ORIGINS=https://kanban.example.com,http://192.168.1.100:3001
```

No trailing slashes. The origin must match exactly what the browser sends in the `Origin` header.

---

### WebSocket connection fails or disconnects immediately

1. Check that your reverse proxy forwards WebSocket upgrade headers:
   - **nginx:** Needs `proxy_set_header Upgrade $http_upgrade; Connection "upgrade";` in the `/ws` location block.
   - **Caddy:** Handles WebSocket automatically — no config needed.
2. Check proxy timeout: WebSocket connections are long-lived. Set `proxy_read_timeout 86400s` in nginx.
3. Verify `CORS_ORIGINS` includes the WebSocket origin.

---

### Assets 404 after sub-path deployment

**Cause:** Frontend was built without `VITE_BASE_PATH`.

**Fix:** Rebuild with the correct base path:

```bash
VITE_BASE_PATH=/kanban/ pnpm --filter @veritas-kanban/web build
```

Or with Docker:

```bash
docker build --build-arg VITE_BASE_PATH=/kanban/ -t veritas-kanban .
```

---

### Rate limiting errors (ERR_ERL_UNEXPECTED_X_FORWARDED_FOR)

**Cause:** Your reverse proxy sends `X-Forwarded-For` but `TRUST_PROXY` is not set.

**Fix:**

```env
TRUST_PROXY=1
```

---

### Tailscale: "ERR_TOO_MANY_REDIRECTS" or assets not loading

**Cause:** `VITE_BASE_PATH` not set when using Tailscale Serve with sub-path routing.

**Fix:** Rebuild with `VITE_BASE_PATH=/kanban/` (see [Tailscale Serve](#tailscale-serve) above).

---

### Weak admin key warning at startup

The server rejects or warns on `VERITAS_ADMIN_KEY` shorter than 32 characters. Generate a proper key:

```bash
openssl rand -hex 32
```

---

### Sessions reset after container restart

**Cause:** `VERITAS_JWT_SECRET` is not set, so a new secret is generated each startup.

**Fix:** Set a persistent JWT secret:

```bash
openssl rand -hex 64
# Add to docker-compose.yml environment or server/.env
VERITAS_JWT_SECRET=<output>
```

---

### Check server health

```bash
curl http://localhost:3001/health
# → {"status":"ok","timestamp":"..."}
```

Auth diagnostics (requires admin key):

```bash
curl -H "X-API-Key: your-admin-key" http://localhost:3001/api/auth/diagnostics
```

---

_For general deployment (Docker, bare metal, systemd, reverse proxy) see also [docs/DEPLOYMENT.md](../DEPLOYMENT.md)._
