Skip to content

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

You do not need Node.js installed on the VPS. Everything runs inside containers.

One-time setup

1. Clone the repo

git clone https://github.com/WalrusQuant/cadence.git
cd cadence

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

docker compose up -d --build

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:

docker compose logs -f app

Verify:

curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3000/dashboard
# 200

4. Tailscale

sudo tailscale up

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:

cd cadence
git pull
docker compose up -d --build app

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: (not image:). If you instead followed the prebuilt-image quick-start (compose uses image: ghcr.io/...), update with docker compose pull && docker compose up -d--build has nothing to build there.

View logs:

docker compose logs -f app
docker compose logs -f postgres

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:

docker compose exec -T postgres pg_dump -U cadence cadence | gzip > backup-$(date +%F).sql.gz

Restore:

gunzip -c backup-YYYY-MM-DD.sql.gz | docker compose exec -T postgres psql -U cadence cadence

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_URL hostname is postgres (the compose service name), not localhost.
  • 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 as docker-compose.yml, or use docker compose --env-file <path> up.
  • MCP 401MCP_API_KEY in .env doesn't match the Authorization: Bearer header from the client.
  • Dashboard shows no data after wipe — check profiles row 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.