VPS Deploy (from source)¶
Most users should follow the quick-start guide instead. It uses the prebuilt container image, skips the clone + build step, and runs migrations automatically. This page is for contributors who want to build the image from source or customize it.
End-to-end deployment for a self-hosted single-user VPS. The dashboard is reachable via Tailscale only; the MCP server is protected by a bearer token (MCP_API_KEY).
Prerequisites on the VPS¶
- Docker Engine + Docker Compose v2 (install guide)
- Tailscale (install guide) — for gating dashboard access
- git
You do not need Node.js installed on the VPS. Everything runs inside containers.
One-time setup¶
1. Clone the repo¶
2. Generate secrets + write .env¶
cat > .env <<EOF
# Database password (the compose file builds DATABASE_URL from this —
# Postgres is only reachable inside the Docker network)
POSTGRES_USER=cadence
POSTGRES_PASSWORD=$(openssl rand -hex 16)
POSTGRES_DB=cadence
# Single-user identity — fresh UUID
SELF_HOSTED_USER_ID=$(node -e "console.log(require('crypto').randomUUID())" 2>/dev/null || uuidgen)
# MCP bearer token — 32 random bytes hex
MCP_API_KEY=$(openssl rand -hex 32)
EOF
Save a copy of the file somewhere safe — it contains all your secrets. (You don't set DATABASE_URL yourself: docker-compose.yml constructs it from the POSTGRES_* values.)
Compose auto-loads .env, so no --env-file flag needed.
3. Build + start everything¶
On first boot the app container's entrypoint waits for Postgres to be healthy, runs any pending Drizzle migrations, and seeds the profiles row for SELF_HOSTED_USER_ID (idempotent — safe on every restart). No manual migrate/seed step needed.
Watch the logs until you see Ready in Xms:
Verify:
4. Tailscale¶
Follow the auth link. To reach the dashboard from your tailnet devices, change the app.ports bind in docker-compose.yml from 127.0.0.1 to your Tailscale IP (e.g. "100.x.y.z:3000:3000") and docker compose up -d. Magic DNS then gives it a nice name like http://vps:3000.
A note on firewalls: don't rely on ufw to protect Docker-published ports — Docker inserts its own iptables rules ahead of ufw's, so ufw deny 3000/tcp has no effect on them. What keeps this deployment private is the port bind itself: Postgres isn't published at all, and the app is bound to 127.0.0.1 (or your Tailscale IP, which only tailnet devices can reach). Never bind to 0.0.0.0.
OpenClaw connection¶
OpenClaw needs one MCP server entry pointing at the VPS's tailnet address with the bearer token you saved in .env.
Example OpenClaw MCP config (the exact format depends on your OpenClaw skill/config layer — adapt as needed):
{
"mcpServers": {
"cadence": {
"url": "http://<vps-tailnet-name>:3000/api/mcp",
"headers": {
"Authorization": "Bearer <MCP_API_KEY from .env>"
}
}
}
}
Verify from your laptop (on the tailnet):
curl -s -X POST -H "Authorization: Bearer $MCP_API_KEY" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"curl","version":"1.0"}}}' \
http://<vps-tailnet-name>:3000/api/mcp
You should get back a JSON-RPC response listing server capabilities.
Day-to-day operations¶
Update to latest code:
The entrypoint runs any pending migrations on container start.
This is the from-source path — it rebuilds the image locally, so the compose file must use
build:(notimage:). If you instead followed the prebuilt-image quick-start (compose usesimage: ghcr.io/...), update withdocker compose pull && docker compose up -d—--buildhas nothing to build there.
View logs:
Back up the database:
The default compose includes a db-backup sidecar that runs nightly pg_dump into ./backups/ with retention — you don't need to do anything to get scheduled backups. See backup-restore.md for tuning and restore instructions.
For an ad-hoc on-demand dump:
Restore:
Wipe all productivity data (keeping schema + profile):
Use the Danger Zone in Settings → "Wipe All Data". Or directly:
docker compose exec -T postgres psql -U cadence cadence <<'EOF'
TRUNCATE tasks, habits, habit_logs, journal_entries, workout_templates,
workout_exercises, workout_logs, workout_log_exercises, focus_sessions,
goals, goal_progress_logs, spaces, tags, weekly_reviews,
daily_briefings, insight_cache CASCADE;
EOF
Troubleshooting¶
- App can't reach Postgres — check
DATABASE_URLhostname ispostgres(the compose service name), notlocalhost. SELF_HOSTED_USER_ID is not set— the app container didn't pick up.env. Make sure the file is named exactly.env(not.env.local) in the same directory asdocker-compose.yml, or usedocker compose --env-file <path> up.- MCP 401 —
MCP_API_KEYin.envdoesn't match theAuthorization: Bearerheader from the client. - Dashboard shows no data after wipe — check
profilesrow still exists (docker compose exec postgres psql -U cadence -c "SELECT * FROM profiles"). If it's gone,docker compose restart app— the entrypoint re-seeds it.