Deploy Z.E.N. on a server with Docker. Includes automatic HTTPS, PostgreSQL, and the Web UI.
Cloud-Init (Fastest Setup)
The fastest way to deploy. Paste the cloud-init config into your VPS provider's User Data field when creating a server; it installs everything automatically.
File: deploy/cloud-init.yml
How to use
- Create a VPS (Ubuntu 22.04+ recommended) at DigitalOcean, AWS, Linode, Hetzner, etc.
- Paste the contents of
deploy/cloud-init.ymlinto the "User Data" / "Cloud-Init" field - Add your SSH key via the provider's UI
- Create the server and wait ~5-8 minutes for setup to complete
What it installs
- Docker + Docker Compose
- UFW firewall (ports 22, 80, 443)
- Clones the repo to
/opt/zen - Copies
.env.example->.envandCaddyfile.example->Caddyfile - Pre-pulls PostgreSQL and Caddy images
- Builds the Z.E.N. Docker image
After boot
SSH into the server and finish configuration:
# Check setup completed
cat /opt/zen/SETUP_COMPLETE
# Edit credentials and domain
nano /opt/zen/.env
# Set at minimum:
# CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-...
# DOMAIN=zen.example.com
# DATABASE_URL=postgresql://postgres:postgres@postgres:5432/zen
# (Optional) Set up basic auth to protect Web UI:
# docker run caddy caddy hash-password --plaintext 'YOUR_PASSWORD'
# Add to .env: CADDY_BASIC_AUTH=basicauth @protected { admin $$2a$$14$$<hash> }
# Start
cd /opt/zen
docker compose --profile with-db --profile cloud up -dDon't forget DNS: Before starting, point your domain's A record to the server's IP.
Provider-specific notes
| Provider | Where to paste cloud-init |
|---|---|
| DigitalOcean | Create Droplet -> Advanced Options -> User Data |
| AWS EC2 | Launch Instance -> Advanced Details -> User Data |
| Linode | Create Linode -> Add Tags -> Metadata (User Data) |
| Hetzner | Create Server -> Cloud config -> User Data |
| Vultr | Deploy -> Additional Features -> Cloud-Init User-Data |
Local Docker Desktop (Windows / macOS)
Run Z.E.N. locally with Docker Desktop; no domain, no VPS required. Uses SQLite and the Web UI only.
Quick start
git clone https://github.com/Cadence-Intelligence/zen.git
cd zen
cp .env.example .env
# Edit .env: set CLAUDE_CODE_OAUTH_TOKEN or CLAUDE_API_KEY
docker compose up -dAccess the Web UI at http://localhost:3090.
Windows-specific notes
Build from WSL, not PowerShell. Docker Desktop on Windows cannot follow Bun workspace symlinks during the build context transfer. If you see The file cannot be accessed by the system, open a WSL terminal:
cd /mnt/c/Users/YourName/path/to/Z.E.N.
docker compose up -dLine endings: The repo uses .gitattributes to force LF endings for shell scripts. If you cloned before this was added and see exec docker-entrypoint.sh: no such file or directory, re-clone or run:
git rm --cached -r .
git reset --hardWhat you get
| Feature | Status |
|---|---|
| Web UI | http://localhost:3090 |
| Database | SQLite (automatic, zero setup) |
| HTTPS / Caddy | Not needed locally |
| Auth | None (single-user, localhost only) |
| Platform adapters | Optional (Telegram, Slack, etc.) |
Using PostgreSQL locally (optional)
docker compose --profile with-db up -dThen add to .env:
DATABASE_URL=postgresql://postgres:postgres@postgres:5432/zenManual Server Setup
Step-by-step alternative if you prefer not to use cloud-init, or need more control.
1. Install Docker
# On Ubuntu/Debian
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER
# Log out and back in for group change to take effect
exit
# ssh back in
# Verify
docker --version
docker compose version2. Clone the repo
git clone https://github.com/Cadence-Intelligence/zen.git
cd zen3. Configure environment
cp .env.example .env
cp Caddyfile.example Caddyfile
nano .envSet these values in .env:
# provider; at least one is required
# Option A: Claude OAuth token (run `claude setup-token` on your local machine to get one)
CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-xxxxx
# Option B: Claude API key (from console.anthropic.com/settings/keys)
# CLAUDE_API_KEY=sk-ant-xxxxx
# Domain; your domain or subdomain pointing to this server
DOMAIN=zen.example.com
# Database; connect to the Docker PostgreSQL container
# Without this, the app uses SQLite (fine for getting started, but PostgreSQL recommended)
DATABASE_URL=postgresql://postgres:postgres@postgres:5432/zen
# Basic Auth (optional); protects Web UI when exposed to the internet
# Skip if using IP-based firewall rules instead.
# Generate hash: docker run caddy caddy hash-password --plaintext 'YOUR_PASSWORD'
# CADDY_BASIC_AUTH=basicauth @protected { admin $$2a$$14$$... }
# Platform tokens (set the ones you use)
# TELEGRAM_BOT_TOKEN=123456789:ABCdef...
# SLACK_BOT_TOKEN=xoxb-...
# SLACK_APP_TOKEN=xapp-...
# GH_TOKEN=ghp_...
# GITHUB_TOKEN=ghp_...Docker does not support
CLAUDE_USE_GLOBAL_AUTH=true; there is no localclaudeCLI inside the container. You must provide eitherCLAUDE_CODE_OAUTH_TOKENorCLAUDE_API_KEYexplicitly.If you use
--profile with-dbwithout settingDATABASE_URL, the app will fall back to SQLite and log a warning. The PostgreSQL container runs but is unused.
4. Point your domain to the server
Create a DNS A record at your domain registrar:
| Type | Name | Value |
|---|---|---|
| A | zen (or @ for root domain) | Your server's public IP |
Wait for DNS propagation (usually 5-60 minutes). Verify with dig zen.example.com.
5. Open firewall ports
sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443
sudo ufw --force enable6. Start
docker compose --profile with-db --profile cloud up -dThis starts three containers:
- app; Z.E.N. server + Web UI
- postgres; PostgreSQL 17 database (auto-initialized)
- caddy; Reverse proxy with automatic HTTPS (Let's Encrypt)
7. Verify
# Check all containers are running
docker compose --profile with-db --profile cloud ps
# Watch logs
docker compose logs -f app
docker compose logs -f caddy
# Test HTTPS (from your local machine)
curl https://zen.example.com/api/healthOpen https://zen.example.com in your browser; you should see the Z.E.N. Web UI.
Profiles
Z.E.N. uses Docker Compose profiles to optionally add PostgreSQL and/or HTTPS. Mix and match:
| Command | What runs |
|---|---|
docker compose up -d | App with SQLite |
docker compose --profile with-db up -d | App + PostgreSQL |
docker compose --profile cloud up -d | App + Caddy (HTTPS) |
docker compose --profile with-db --profile cloud up -d | App + PostgreSQL + Caddy |
INFO
There is no external-db profile. When using an external PostgreSQL database (Supabase, Neon, etc.), just set DATABASE_URL in .env and run docker compose up -d without any profile. The base app service always starts.
No profile (SQLite)
Zero-config default. No database container needed; SQLite file is stored in the zen_data volume.
--profile with-db (PostgreSQL)
Starts a PostgreSQL 17 container. Set the connection URL in .env:
DATABASE_URL=postgresql://postgres:postgres@postgres:5432/zenThe schema is auto-initialized on first startup. PostgreSQL is exposed on ${POSTGRES_PORT:-5432} for external tools.
--profile cloud (Caddy HTTPS)
Adds a Caddy reverse proxy with automatic TLS certificates from Let's Encrypt.
Requires before starting:
Caddyfilecreated:cp Caddyfile.example CaddyfileDOMAINset in.env- DNS A record pointing to your server's IP
- Ports 80 and 443 open
Caddy handles HTTPS certificates, HTTP->HTTPS redirect, HTTP/3, and SSE streaming.
Authentication (Optional Basic Auth)
Caddy can enforce HTTP Basic Auth on all routes except webhooks (/webhooks/*) and the health check (/api/health). This is optional; skip it if you use IP-based firewall rules or other network-level access control.
To enable:
Generate a bcrypt password hash:
bashdocker run caddy caddy hash-password --plaintext 'YOUR_PASSWORD'Set
CADDY_BASIC_AUTHin.env(use$$to escape$in bcrypt hashes):iniCADDY_BASIC_AUTH=basicauth @protected { admin $$2a$$14$$abc123... }Restart:
docker compose --profile cloud restart caddy
Your browser will prompt for username/password when accessing the Z.E.N. URL. Webhook endpoints bypass auth since they use HMAC signature verification.
To disable, leave CADDY_BASIC_AUTH empty or unset; the Caddyfile expands it to nothing.
Important: Always use the
docker run caddy caddy hash-passwordcommand to generate hashes; never put plaintext passwords in.env.
Form-Based Authentication (HTML Login Page)
An alternative to basic auth that serves a styled HTML login form instead of the browser's credential popup. Uses a lightweight auth-service sidecar and Caddy's forward_auth directive.
When to use form auth vs basic auth:
- Form auth: Styled dark-mode login page, 24h session cookie, logout support. Requires an extra container.
- Basic auth: Zero extra containers, simpler setup. Browser shows a native credential dialog.
Setup:
Generate a bcrypt password hash:
bashdocker compose --profile auth run --rm auth-service \ node -e "require('bcryptjs').hash('YOUR_PASSWORD', 12).then(h => console.log(h))"First run builds the auth-service image. Save the output hash (starts with
$2b$12$...).Generate a random cookie signing secret:
bashdocker run --rm node:22-alpine \ node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"Set the following in
.env:iniAUTH_USERNAME=admin AUTH_PASSWORD_HASH=$2b$12$REPLACE_WITH_YOUR_HASH COOKIE_SECRET=REPLACE_WITH_64_HEX_CHARSUpdate
Caddyfile(copy fromCaddyfile.exampleif not done yet):- Uncomment the "Option A" form auth block (the
handle /login,handle /logout, andhandle { forward_auth ... }blocks) - Comment out the "No auth" default
handleblock (the lasthandle { ... }block near the bottom of the site block)
- Uncomment the "Option A" form auth block (the
Start with both
cloudandauthprofiles:bashdocker compose --profile with-db --profile cloud --profile auth up -dVisit your domain; you should be redirected to
/login.
Logout: Navigate to /logout to clear the session cookie and return to the login form.
Session duration: Defaults to 24 hours (COOKIE_MAX_AGE=86400). Override in .env:
COOKIE_MAX_AGE=3600 # 1 hourNote: Do not use form auth and basic auth simultaneously. Choose one method and leave the other disabled (either empty
CADDY_BASIC_AUTHor remove the basic auth@protectedblock from your Caddyfile).
Configuration
Port Defaults
WARNING
Both local and Docker setups should use 3090. The PORT env var sets it; .env carries the value.
The Docker healthcheck uses /api/health (not /health):
# Inside Docker
curl http://localhost:3090/api/health
# Local development (both work)
curl http://localhost:3090/health
curl http://localhost:3090/api/healthAI Credentials (required)
Docker containers cannot use CLAUDE_USE_GLOBAL_AUTH=true; there is no local claude CLI inside the container. You must set credentials explicitly in .env:
Claude (choose one):
# OAuth token; run `claude setup-token` on your local machine, copy the token
CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-xxxxx
# Or API key; from console.anthropic.com/settings/keys
CLAUDE_API_KEY=sk-ant-xxxxxCodex (alternative):
CODEX_ID_TOKEN=eyJhbGc...
CODEX_ACCESS_TOKEN=eyJhbGc...
CODEX_REFRESH_TOKEN=rt_...
CODEX_ACCOUNT_ID=6a6a7ba6-...Platform Tokens (optional)
TELEGRAM_BOT_TOKEN=123456789:ABCdef...
SLACK_BOT_TOKEN=xoxb-...
SLACK_APP_TOKEN=xapp-...
GH_TOKEN=ghp_...
GITHUB_TOKEN=ghp_...
WEBHOOK_SECRET=...Server Settings (optional)
PORT=3090 # Default: 3090
DOMAIN=zen.example.com # Required for --profile cloud
LOG_LEVEL=info # fatal|error|warn|info|debug|trace
MAX_CONCURRENT_CONVERSATIONS=10See .env.example for the full list with documentation.
Data Directory
The container stores all data at /.zen/ (workspaces, worktrees, artifacts, logs, SQLite DB).
By default this is a Docker-managed volume. To store data at a specific location on the host, set ZEN_DATA in .env:
# Store Z.E.N. data at a specific host path
ZEN_DATA=/opt/zen-dataThe directory is created automatically. Make sure the path is writable by UID 1001 (the container user):
mkdir -p /opt/zen-data
sudo chown -R 1001:1001 /opt/zen-dataIf ZEN_DATA is not set, Docker manages the volume automatically (zen_data); data persists across restarts and rebuilds but lives inside Docker's storage.
GitHub CLI Authentication
GH_TOKEN from .env is picked up automatically. Alternatively:
docker compose exec app gh auth loginGitHub Webhooks
After the server is reachable via HTTPS:
- Go to
https://github.com/<owner>/<repo>/settings/hooks - Add webhook:
- Payload URL:
https://zen.example.com/webhooks/github - Content type:
application/json - Secret: Your
WEBHOOK_SECRETfrom.env - Events: Issues, Issue comments, Pull requests
- Payload URL:
Pre-built Image
For users who don't need to build from source:
mkdir zen && cd zen
curl -O https://raw.githubusercontent.com/Cadence-Intelligence/zen/main/deploy/docker-compose.yml
curl -O https://raw.githubusercontent.com/Cadence-Intelligence/zen/main/.env.example
cp .env.example .env
# Edit .env; set AI credentials, DOMAIN, etc.
docker compose up -dUses ghcr.io/Cadence-Intelligence/zen:latest. To add PostgreSQL, uncomment the postgres service in the compose file and set DATABASE_URL in .env.
To layer custom tools on top of the pre-built image, see Customizing the Image.
Building the Image
The Dockerfile uses three stages:
- deps; Installs all dependencies (including devDependencies for the web build)
- web-build; Builds the React web UI with Vite
- production; Production image with only production dependencies + pre-built web assets
docker build -t zen .
docker run --env-file .env -p 3090:3090 zenWhat's in the image:
- Runtime: Bun 1.2 (runs TypeScript directly, no compile step)
- System deps: git, curl, gh (GitHub CLI), postgresql-client, Chromium
- Browser tooling: agent-browser (Vercel Labs); enables E2E testing workflows via CDP. Uses system Chromium (
AGENT_BROWSER_EXECUTABLE_PATH=/usr/bin/chromium) - App: All 10 workspace packages (source), pre-built web UI
- User: Non-root
appuser(UID 1001); required by Claude Code SDK - Z.E.N. dirs:
/.zen/workspaces,/.zen/worktrees
The multi-stage build keeps the image lean; no devDependencies, test files, docs, or .git/.
Customizing the Image
To add extra tools without modifying the tracked Dockerfile:
- Copy the example:
- Local/dev:
cp Dockerfile.user.example Dockerfile.user - Server/deploy:
cp deploy/Dockerfile.user.example Dockerfile.user
- Local/dev:
- Edit
Dockerfile.user; uncomment and extend the examples as needed. - Copy the override file:
- Local/dev:
cp docker-compose.override.example.yml docker-compose.override.yml - Server/deploy:
cp deploy/docker-compose.override.example.yml docker-compose.override.yml
- Local/dev:
- Run
docker compose up -d; Compose merges the override automatically.
Dockerfile.user and docker-compose.override.yml are gitignored so your customizations stay local.
Maintenance
View Logs
docker compose logs -f # All services
docker compose logs -f app # App only
docker compose logs --tail=100 app # Last 100 linesUpdate
git pull
docker compose --profile with-db --profile cloud up -d --buildRestart
docker compose restart # All
docker compose restart app # App onlyStop
docker compose down # Stop containers (data preserved)
docker compose down -v # Stop + delete volumes (destructive!)Database Migrations (PostgreSQL)
Migrations run automatically on first startup via 000_combined.sql. When upgrading to a newer version that adds database tables, you need to apply incremental migrations manually:
# Example: apply the env vars migration (required when upgrading to v0.3.x)
docker compose exec postgres psql -U postgres -d zen -f /migrations/020_codebase_env_vars.sqlThe migrations/ directory is mounted read-only into the postgres container. Check for any new migration files after pulling updates.
Clean Up Docker Resources
docker system prune -a # Remove unused images/containers
docker volume prune # Remove unused volumes (caution!)
docker system df # Check disk usageTroubleshooting
App won't start: "no_ai_credentials"
No provider configured. Docker does not support CLAUDE_USE_GLOBAL_AUTH=true. Set one of these in .env:
CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-...(runclaude setup-tokenlocally to get one)CLAUDE_API_KEY=sk-ant-...(from console.anthropic.com)- Or Codex credentials (
CODEX_ID_TOKEN,CODEX_ACCESS_TOKEN, etc.)
Caddy fails to start: "not a directory"
error mounting "Caddyfile": not a directoryThe Caddyfile doesn't exist; Docker created a directory in its place. Fix:
rm -rf Caddyfile
cp Caddyfile.example Caddyfile
docker compose --profile cloud up -dCaddy not getting SSL certificate
# Check DNS propagation
dig zen.example.com
# Should return your server IP
# Check Caddy logs
docker compose logs caddy
# Check firewall
sudo ufw status
# Ports 80 and 443 must be openCommon causes: DNS not propagated (wait 5-60min), firewall blocking 80/443, domain typo in .env.
Health check failing
The Docker healthcheck uses /api/health (not /health):
curl http://localhost:3090/api/healthPostgreSQL connection refused
When using --profile with-db, ensure:
DATABASE_URLusespostgresas hostname (Docker service name), notlocalhost:iniDATABASE_URL=postgresql://postgres:postgres@postgres:5432/zen- The postgres container is healthy:
docker compose ps postgres - Migrations ran: check
docker compose logs postgresfor init script output
Permission errors in /.zen/
The container runs as appuser (UID 1001). If using bind mounts instead of Docker volumes:
sudo chown -R 1001:1001 /path/to/zen-dataPort conflicts
Set PORT=3090 in .env:
PORT=3001Container keeps restarting
docker compose ps
docker compose logs --tail=50 appCommon causes: missing .env file, invalid credentials, database unreachable.