Planekeeper Deployment Guide
This guide covers deploying Planekeeper using Docker Compose with a split Traefik architecture for security.
Architecture Overview
The deployment uses two separate Traefik reverse proxy instances:
- Public Traefik (ports 80/443): Exposes only client-facing endpoints
- Internal Traefik (port 8443): Exposes admin/internal endpoints (secured by host firewall)
Internet
│
▼
┌───────────────────────────────┐
│ traefik-public (80/443) │
│ - Client UI (/) │
│ - Client API (/api/v1/client)│
│ - Health (/health) │
└───────────────┬───────────────┘
│
────────────────┼──────────────── (network boundary)
│
┌───────────────┼───────────────┐
│ ▼ │
│ ┌─────────┐ ┌─────────────┐ │
│ │ API │ │ Client UI │ │
│ └────┬────┘ └─────────────┘ │
│ │ │
│ ┌────┴────┐ ┌─────────────┐ │
│ │PostgreSQL│ │ Internal UI │ │
│ └─────────┘ └──────┬──────┘ │
│ │ │
│ ┌─────────────┐ ┌────────────┐│
│ │ TaskEngine │ │ServerAgent ││
│ └─────────────┘ └────────────┘│
│ ┌─────────────┐ │
│ │ Notifier │ │
│ └─────────────┘ │
└─────────────────────┬─────────┘
│
┌─────────────────────▼─────────┐
│ traefik-internal (8443/8082) │
│ - Internal UI (/) │
│ - Internal API (/api/v1/internal)│
│ - Traefik Dashboard (:8082) │
└───────────────────────────────┘
▲
│
Firewall-restricted IP
Services
| Service | Description | Replicas |
|---|---|---|
traefik | Public reverse proxy (SSL termination) | 1 |
traefik-internal | Internal reverse proxy | 1 |
postgres | PostgreSQL database | 1 |
api | API server (runs migrations) | 2+ |
clientui | Public-facing UI | 1+ |
internalui | Admin UI | 1 |
taskengine | Job scheduler | 1 |
serveragent | Task executor (co-located with server) | 1 |
eolsync | EOL data sync (cron) | 1 |
notifier | Notification delivery worker | 1+ |
docs | docs.planekeeper.com) | — |
internal-docs | internal-docs.planekeeper.com, GitHub OAuth) | — |
Agent Deployment
Agents can run either locally (in the Docker stack) or remotely (at client sites).
Local Agent (in Docker stack)
The local agent runs in the same Docker network and connects directly to the API:
# docker/config/agent.yaml or environment variable
agent:
server_url: http://api:3000/api/v1/internal
api_key: pk_xxx
Remote Agents (client sites)
Remote agents connect over the public internet through Traefik. The agent endpoints (/api/v1/internal/heartbeat, /api/v1/internal/tasks) are exposed publicly but require API key authentication.
Security notes:
- Agent endpoints require valid API key authentication
- All traffic is encrypted via TLS
- Other internal endpoints (admin UI, metrics) remain internal-only
- Consider creating separate API keys per client site for easier rotation
Client Site Deployment
For deploying agents at client sites, use the simplified docker-compose.client.yml.
Quick Start (Client)
1. Download the client files:
mkdir planekeeper-agent && cd planekeeper-agent
# Download compose file and config
curl -O https://raw.githubusercontent.com/rhoat/client-sites/main/go/planekeeper/docker/docker-compose.client.yml
curl -O https://raw.githubusercontent.com/rhoat/client-sites/main/go/planekeeper/docker/.env.client.example
mkdir -p config
curl -o config/agent.yaml https://raw.githubusercontent.com/rhoat/client-sites/main/go/planekeeper/docker/config/agent.client.yaml
2. Configure:
cp .env.client.example .env
nano .env
Required settings:
AGENT_SERVER_URL=https://planekeeper.example.com/api/v1/internal
AGENT_API_KEY=pk_xxx
3. Start the agent:
docker compose -f docker-compose.client.yml up -d
4. Verify connection:
docker compose -f docker-compose.client.yml logs -f
You should see successful heartbeat messages:
level=info msg="initial heartbeat sent"
level=info msg="starting polling loop"
Client Agent Operations
View logs:
docker compose -f docker-compose.client.yml logs -f
Restart agent:
docker compose -f docker-compose.client.yml restart
Stop agent:
docker compose -f docker-compose.client.yml down
Update agent:
docker compose -f docker-compose.client.yml pull
docker compose -f docker-compose.client.yml up -d
Private Repository Access
To allow the agent to scrape private repositories, configure credentials in a config/agent.yaml file (see config/agent.yaml.example). The file is mounted into the container at /etc/planekeeper/config.yaml.
Option 1: SSH Key (file-based)
Mount both the config file and SSH key into the container:
# In docker-compose.client.yml
volumes:
- ./config/agent.yaml:/etc/planekeeper/config.yaml:ro
- ~/.ssh/id_ed25519:/ssh/id_ed25519:ro
Configure in config/agent.yaml:
agent:
credentials:
my-github:
type: ssh_key
private_key_file: /ssh/id_ed25519
Option 2: SSH Key (inline)
Mount only the config file — the key content is embedded directly:
# In docker-compose.client.yml
volumes:
- ./config/agent.yaml:/etc/planekeeper/config.yaml:ro
Configure in config/agent.yaml:
agent:
credentials:
my-github:
type: ssh_key
private_key: |
-----BEGIN OPENSSH PRIVATE KEY-----
...key content...
-----END OPENSSH PRIVATE KEY-----
Option 3: HTTPS Personal Access Token
Configure in config/agent.yaml:
agent:
credentials:
my-gitlab:
type: https_pat
token: glpat-xxxxxxxxxxxx
Troubleshooting (Client)
Agent can’t connect (connection refused):
- Verify
AGENT_SERVER_URLis correct and includes/api/v1/internal - Check firewall allows outbound HTTPS (port 443)
- Test connectivity:
curl -I https://planekeeper.example.com/health
Agent gets 401 Unauthorized:
- Verify
AGENT_API_KEYis correct - Check the API key hasn’t been revoked on the server
Agent gets 404 Not Found:
- Ensure URL includes
/api/v1/internalpath - Verify the server has agent endpoints exposed publicly
No tasks being assigned:
- Check the agent appears in the server’s admin UI
- Verify jobs are configured to run on this agent’s organization
Prerequisites
- Docker and Docker Compose v2+
- Domain name with DNS pointing to your server
- Cloudflare account (for DNS-01 SSL challenge)
Quick Start
1. Clone and Configure
cd go/planekeeper/docker
# Copy environment template
cp .env.example .env
# Edit configuration
nano .env
2. Configure Environment Variables
Required variables in .env:
# Domain and SSL
DOMAIN=planekeeper.example.com
ACME_EMAIL=admin@example.com
CF_DNS_API_TOKEN=your-cloudflare-api-token
# Database
PG_USER=planekeeper
PG_PASSWORD=your-secure-password
PG_DBNAME=planekeeper
# Agent (set after first startup)
AGENT_API_KEY=pk_xxx
3. Start Services
# From go/planekeeper/docker/
docker compose up -d
4. Create API Key
After the services start, create an API key for the agent:
Create an API key from the API Keys page in the client UI.
Add the generated key to your .env file as SERVERAGENT_API_KEY, then restart the serveragent:
docker compose -f go/planekeeper/docker/go/planekeeper/docker/docker-compose.yml restart serveragent
Accessing the Services
Public Endpoints
| URL | Description |
|---|---|
https://www.planekeeper.com/ | Client UI |
https://www.planekeeper.com/login | Client login |
https://www.planekeeper.com/api/v1/client/* | Client API |
https://www.planekeeper.com/health | Health check |
Internal Endpoints (port 8443, firewall-restricted)
| URL | Description |
|---|---|
https://admin.planekeeper.com:8443/ | Internal service portal (no auth required) |
https://admin.planekeeper.com:8443/login | Internal/Admin UI login |
https://admin.planekeeper.com:8443/api/v1/internal/* | Internal API |
https://admin.planekeeper.com:8443/api/v1/internal/metrics | Prometheus metrics (no auth required) |
http://localhost:8082/dashboard/ | Traefik dashboard |
Accessing Internal Endpoints
Port 8443 binds to 0.0.0.0 (all interfaces) in the production compose. Access control is provided by your hosting provider’s firewall — add an inbound rule for TCP port 8443 restricted to your IP.
Note: The Traefik dashboard (port 8082) binds to
127.0.0.1and requires an SSH tunnel.
Option 1: Direct access (with firewall rule)
Add a firewall rule in your hosting provider’s panel allowing TCP 8443 from your IP, then access https://admin.planekeeper.com:8443/ directly.
Option 2: SSH Tunnel (no firewall rule needed)
ssh -L 8443:localhost:8443 -L 8082:localhost:8082 user@your-server
# Then access https://localhost:8443/ from your local machine
Option 3: VPN Configure your VPN to route traffic to the internal port.
Traefik Routing
Public Traefik (ports 80/443)
Routes exposed to the internet:
| Route | Service | Purpose |
|---|---|---|
/api/v1/client/* | api | Client API endpoints |
/api/v1/internal/heartbeat/* | api | Agent heartbeat (API key required) |
/api/v1/internal/tasks/* | api | Agent task polling (API key required) |
/api/v1/swagger/* | api | Client API documentation |
/api/spec/* | api | OpenAPI spec files |
/health | api | Health check endpoint |
/* | clientui | Client UI (catch-all) |
Internal Traefik (port 8443)
Routes accessible via direct connection (with firewall rule), SSH tunnel, or VPN:
| Route | Service | Purpose | Auth |
|---|---|---|---|
/api/v1/internal/metrics | api | Prometheus metrics | None |
/api/v1/internal/* | api | Full internal API | API key |
/api/spec/* | api | OpenAPI spec files | None |
/health | api | Health check endpoint | None |
/* | internalui | Admin UI (catch-all) | Cookie |
Note: The /metrics endpoint requires no authentication — security is provided by the host firewall restricting access to port 8443.
Route Priority
Routes are matched by priority (higher = matched first):
- Priority 100: Specific API routes
- Priority 50: General
/api/*catch-all - Priority 1: UI catch-all
Cloudflare DNS-01 Setup
Create API Token
- Go to Cloudflare API Tokens
- Click “Create Token”
- Use “Edit zone DNS” template or create custom:
- Permissions: Zone → DNS → Edit
- Zone Resources: Include → Specific zone → your domain
- Copy the token to
CF_DNS_API_TOKENin.env
Alternative: Global API Key
If you prefer the legacy method:
CF_API_EMAIL=your-cloudflare-email@example.com
CF_API_KEY=your-global-api-key
Scaling
Scale API Servers
docker compose up -d --scale api=3
Scale ServerAgents
docker compose up -d --scale serveragent=2
Configuration Files
Directory Structure
go/planekeeper/docker/
├── go/planekeeper/docker/docker-compose.yml # All service definitions
├── .env.example # Environment template
├── .env # Your configuration (git-ignored)
├── data/
│ └── postgres/ # PostgreSQL data (bind mount, git-ignored)
│ └── 18/data/ # Version-specific data directory
├── config/
│ ├── api.yaml # API server config
│ ├── clientui.yaml # Client UI config
│ ├── internalui.yaml # Internal UI config
│ ├── taskengine.yaml # TaskEngine config
│ ├── agent.yaml # Agent config
│ └── eolsync.yaml # EOLSync config
├── traefik/
│ ├── traefik-public.yml # Public Traefik static config
│ ├── traefik-internal.yml # Internal Traefik static config
│ ├── dynamic-public.yml # Public routing rules
│ └── dynamic-internal.yml # Internal routing rules
└── postgres/
└── init/
└── init.sql # Database initialization
Service Configuration
Each service reads from /app/config.yaml mounted from docker/config/. Environment variables override config file values.
Key environment variables:
| Variable | Description |
|---|---|
PG_HOST, PG_PORT, etc. | Database connection |
SERVER_ADDRESS | Listen address (default: 0.0.0.0:3000) |
SERVERAGENT_API_KEY | API key for server-side agent authentication |
GITHUB_TOKEN | GitHub token for higher rate limits |
Notifier Configuration
The notifier service handles webhook notification delivery. Key environment variables:
| Variable | Description | Default |
|---|---|---|
NOTIFICATION_BATCH_SIZE | Deliveries to claim per poll | 100 |
NOTIFICATION_POLL_INTERVAL | How often to check for work | 5s |
NOTIFICATION_BASE_URL | Base URL for acknowledgment callbacks | - |
NOTIFICATION_ALLOW_PRIVATE_URLS | Allow RFC1918/localhost webhooks | false |
NOTIFICATION_MAX_RETRIES | Max retry attempts before dead letter | 12 |
NOTIFICATION_ACK_TOKEN_EXPIRY | Acknowledgment token expiry | 24h |
Scaling the Notifier:
The notifier uses PostgreSQL FOR UPDATE SKIP LOCKED for distributed locking, allowing horizontal scaling:
docker compose up -d --scale notifier=3
Each replica claims different deliveries without coordination.
Operations
View Logs
# All services
docker compose -f go/planekeeper/docker/go/planekeeper/docker/docker-compose.yml logs -f
# Specific service
docker compose -f go/planekeeper/docker/go/planekeeper/docker/docker-compose.yml logs -f api
# Last 100 lines
docker compose -f go/planekeeper/docker/go/planekeeper/docker/docker-compose.yml logs --tail=100 api
Restart Services
# All services
docker compose -f go/planekeeper/docker/go/planekeeper/docker/docker-compose.yml restart
# Specific service
docker compose -f go/planekeeper/docker/go/planekeeper/docker/docker-compose.yml restart api
Stop Services
docker compose -f go/planekeeper/docker/go/planekeeper/docker/docker-compose.yml down
Stop and Remove Volumes (full reset)
docker compose -f go/planekeeper/docker/go/planekeeper/docker/docker-compose.yml down -v
Update Images
# Build images via Bazel (from monorepo root)
bazel build //containers/planekeeper/...
# Or load specific image into local Docker
bazel run //containers/planekeeper:server_load
# Push to registry (with git tag stamping)
bazel run --config=release //containers/planekeeper:server_push
Backup and Restore
Database Storage
PostgreSQL data is stored in a bind mount at docker/data/postgres/ for easier backup and recovery. This location:
- Is excluded from git via
.gitignore - Can be backed up with standard filesystem tools
- Survives Docker reinstalls
- Can be easily migrated to another server
PostgreSQL 18+ Directory Structure
PostgreSQL 18+ Docker images use version-specific subdirectories for data storage. This enables easier upgrades using pg_upgrade --link. The mount point is /var/lib/postgresql (not /var/lib/postgresql/data), and PostgreSQL creates a versioned subdirectory:
docker/data/postgres/
└── 18/
└── data/
├── PG_VERSION
├── base/
├── global/
├── pg_wal/
└── ...
See docker-library/postgres#1259 for details on this change.
Backup Database
Option 1: File-based backup (fastest for full backup)
# Stop postgres first for consistent backup
docker compose -f go/planekeeper/docker/go/planekeeper/docker/docker-compose.yml stop postgres
tar -czf postgres-backup-$(date +%Y%m%d).tar.gz -C docker/data postgres
docker compose -f go/planekeeper/docker/go/planekeeper/docker/docker-compose.yml start postgres
Option 2: SQL dump (portable, can restore to different versions)
docker compose -f go/planekeeper/docker/go/planekeeper/docker/docker-compose.yml exec postgres \
pg_dump -U planekeeper planekeeper > backup.sql
Restore Database
Option 1: From file-based backup
docker compose -f go/planekeeper/docker/go/planekeeper/docker/docker-compose.yml down
rm -rf docker/data/postgres/*
tar -xzf postgres-backup-YYYYMMDD.tar.gz -C docker/data
docker compose -f go/planekeeper/docker/go/planekeeper/docker/docker-compose.yml up -d
Option 2: From SQL dump
docker compose -f go/planekeeper/docker/go/planekeeper/docker/docker-compose.yml exec -T postgres \
psql -U planekeeper planekeeper < backup.sql
Migrating from Docker Volumes
If you have existing PostgreSQL data in a Docker volume and want to migrate to the bind mount setup:
1. Stop the current stack:
docker compose -f go/planekeeper/docker/go/planekeeper/docker/docker-compose.yml down
2. Find your existing volume:
docker volume ls | grep postgres
3. Inspect the old container to find mount points:
docker inspect <container_name> --format '{{range .Mounts}}{{.Type}}: {{.Source}} -> {{.Destination}}{{println}}{{end}}'
Look for volumes mounted to /var/lib/postgresql or /var/lib/postgresql/data. PostgreSQL 18+ may also create anonymous volumes.
4. Explore the volume contents:
docker run --rm -v <volume_name>:/source alpine sh -c "find /source -type d -maxdepth 3"
5. Check which database has your data:
# Start a temporary postgres with the old volume
docker run --rm -d --name postgres-check \
-v <volume_name>:/var/lib/postgresql \
-e POSTGRES_HOST_AUTH_METHOD=trust \
postgres:18.1
# List databases
docker exec postgres-check psql -U postgres -c "\l"
# Check for your data (try both database names)
docker exec postgres-check psql -U postgres -d planekeeper -c "\dt" 2>/dev/null
docker exec postgres-check psql -U postgres -d postgres -c "\dt" 2>/dev/null
# Stop the temp container
docker stop postgres-check
6. Copy the entire volume to the bind mount:
Important: Copy the entire
/var/lib/postgresqlstructure, not just the data subdirectory. This preserves the version-specific directory structure that PostgreSQL 18+ requires.
# Clear any existing data
rm -rf docker/data/postgres/*
# Copy the entire volume structure
docker run --rm \
-v <volume_name>:/source \
-v $(pwd)/docker/data/postgres:/dest \
alpine sh -c "cp -a /source/. /dest/"
# Verify the structure (should show version directory like "18")
ls -la docker/data/postgres/
ls -la docker/data/postgres/18/
7. Update credentials to match your data:
Check what user/database your data uses and update .env accordingly:
PG_USER=planekeeper
PG_PASSWORD=<your-password>
PG_DBNAME=planekeeper
8. Start the stack:
docker compose -f go/planekeeper/docker/go/planekeeper/docker/docker-compose.yml up -d
9. Verify the migration:
docker compose -f go/planekeeper/docker/go/planekeeper/docker/docker-compose.yml exec postgres psql -U planekeeper -d planekeeper -c "\dt"
Backup Let’s Encrypt Certificates
The certificates are stored in the traefik-certs volume:
docker run --rm -v docker_traefik-certs:/data -v $(pwd):/backup \
alpine tar czf /backup/traefik-certs.tar.gz -C /data .
Troubleshooting
Check Service Health
# Service status
docker compose -f go/planekeeper/docker/go/planekeeper/docker/docker-compose.yml ps
# Health endpoint
curl http://localhost/health
Database Connection Issues
# Check postgres logs
docker compose -f go/planekeeper/docker/go/planekeeper/docker/docker-compose.yml logs postgres
# Connect to database
docker compose -f go/planekeeper/docker/go/planekeeper/docker/docker-compose.yml exec postgres \
psql -U planekeeper planekeeper
Connecting to the Database
The PostgreSQL port is not exposed externally for security. Use these methods to connect:
Interactive psql session:
docker compose -f go/planekeeper/docker/go/planekeeper/docker/docker-compose.yml exec postgres psql -U planekeeper -d planekeeper
Run a single query:
docker compose -f go/planekeeper/docker/go/planekeeper/docker/docker-compose.yml exec postgres psql -U planekeeper -d planekeeper -c "SELECT count(*) FROM gather_jobs;"
List tables:
docker compose -f go/planekeeper/docker/go/planekeeper/docker/docker-compose.yml exec postgres psql -U planekeeper -d planekeeper -c "\dt"
List databases:
docker compose -f go/planekeeper/docker/go/planekeeper/docker/docker-compose.yml exec postgres psql -U planekeeper -c "\l"
Run SQL from a file:
docker compose -f go/planekeeper/docker/go/planekeeper/docker/docker-compose.yml exec -T postgres psql -U planekeeper -d planekeeper < query.sql
Using a GUI tool (like DBeaver, pgAdmin):
If you need GUI access, temporarily forward the port via SSH tunnel:
ssh -L 5432:localhost:5432 user@your-server
Then connect your GUI tool to localhost:5432.
Alternatively, for local development, you can temporarily expose the port by adding to go/planekeeper/docker/docker-compose.yml:
postgres:
ports:
- "127.0.0.1:5432:5432" # localhost only
SSL Certificate Issues
# Check Traefik logs for ACME errors
docker compose -f go/planekeeper/docker/go/planekeeper/docker/docker-compose.yml logs traefik | grep -i acme
# Verify Cloudflare token
curl -X GET "https://api.cloudflare.com/client/v4/user/tokens/verify" \
-H "Authorization: Bearer $CF_DNS_API_TOKEN"
Routing Issues
# Check Traefik dashboard
# Public: Not exposed (security)
# Internal: http://localhost:8082/dashboard/
# Test routing manually
curl -v https://admin.planekeeper.com:8443/health
curl -v https://www.planekeeper.com/health
EOLSync Cron Issues
The eolsync container uses supercronic for cron scheduling. Supercronic is built from source during the Docker build using go install (compiled with the project’s Go toolchain to avoid stdlib CVE drift). The crontab is also created during the build. No runtime package installation is needed.
Normal log messages:
level=info msg="reaping dead processes"
level=info msg="read crontab: /app/crontab"
These are normal - supercronic includes a process reaper (like tini) and logs when it reads the crontab.
“Failed to fork exec: no such file or directory” error:
This error means supercronic can’t find or execute the binary. Common causes:
Missing full path in CMD: The Dockerfile CMD must use the full path:
CMD ["/usr/local/bin/supercronic", "/app/crontab"]Binary not built: Check container logs since the runtime image has no shell:
docker logs planekeeper-eolsync
The default schedule is 0 2 * * * (daily at 2 AM UTC).
Trigger a sync immediately (bypass cron):
docker compose -f go/planekeeper/docker/go/planekeeper/docker/docker-compose.yml run --rm --entrypoint /usr/local/bin/eolsync eolsync
PostgreSQL 18+ Data Directory Error
If you see this error:
Error: in 18+, these Docker images are configured to store database data in a
format which is compatible with "pg_ctlcluster"...
Counter to that, there appears to be PostgreSQL data in:
/var/lib/postgresql/data
This means you have data in the old location format. PostgreSQL 18+ expects:
- Mount at
/var/lib/postgresql(not/var/lib/postgresql/data) - Data in version-specific subdirectory:
18/data/
Fix: Reorganize your data:
# If data is directly in docker/data/postgres/
mkdir -p docker/data/postgres/18/data
mv docker/data/postgres/base docker/data/postgres/18/data/
mv docker/data/postgres/global docker/data/postgres/18/data/
mv docker/data/postgres/pg_* docker/data/postgres/18/data/
mv docker/data/postgres/PG_VERSION docker/data/postgres/18/data/
mv docker/data/postgres/postgresql* docker/data/postgres/18/data/
mv docker/data/postgres/postmaster* docker/data/postgres/18/data/
Prometheus Integration
The metrics endpoint provides system-wide operational metrics in Prometheus format. It is accessible without authentication via the internal Traefik.
Prometheus Scrape Configuration
scrape_configs:
- job_name: 'planekeeper'
static_configs:
- targets: ['admin.planekeeper.com:8443'] # Internal Traefik only
metrics_path: '/api/v1/internal/metrics'
# No authentication required - endpoint is on internal network
# Default output is Prometheus format, no params needed
Testing the Metrics Endpoint
# Prometheus format (default)
curl https://admin.planekeeper.com:8443/api/v1/internal/metrics
# JSON format
curl https://admin.planekeeper.com:8443/api/v1/internal/metrics?format=json
Available Metrics
The endpoint exposes system-wide metrics including:
- Organization counts (total, active)
- Service instance health by type (server, agent, taskengine, etc.)
- Job counts by type and status (gather, scrape, helm_sync)
- Alert statistics with severity breakdown
- Release counts (stable, prerelease, unique artifacts)
- Task execution stats (24h window with success rate)
- API key counts (total, active, system)
See Business Logic for complete metric documentation.
Remote Prometheus Access
If Prometheus runs on a different host, use an SSH tunnel:
# On Prometheus host
ssh -L 8443:localhost:8443 user@planekeeper-server
# Then configure Prometheus to scrape localhost:8443
Alternatively, configure your VPN to route traffic to the internal port.
Supabase Authentication (Optional)
Planekeeper supports Supabase Auth for human user authentication in both the Client UI and Internal UI. When configured, users log in with email/password or OAuth (GitHub, etc.) instead of API keys. Agent authentication remains unchanged (API key only).
If Supabase is not configured, both UIs use the legacy API key login flow. The Internal UI additionally gates Supabase login to superadmin users via Casbin g2 rules (email/password only, no OAuth or signup).
Prerequisites
- A Supabase project (cloud or self-hosted)
- The project’s JWT secret, publishable key, and URL
Environment Variables
Add these to your .env file:
| Variable | Required | Description |
|---|---|---|
SUPABASE_URL | Yes | Supabase project URL (e.g., https://xxx.supabase.co) |
SUPABASE_PUBLISHABLE_KEY | Yes (clientui) | Publishable API key for auth requests (sb_publishable_...) |
SUPABASE_JWT_SECRET | Yes | JWT signing secret for token validation (HS256) |
AUTH_CALLBACK_URL | Yes (clientui) | OAuth callback URL (e.g., https://planekeeper.example.com/auth/callback) |
AUTH_COOKIE_SECURE | No | Set cookie Secure flag (default: true, set false for HTTP dev) |
Which Services Need What
| Variable | API Server | ClientUI | InternalUI |
|---|---|---|---|
SUPABASE_JWT_SECRET | Yes (validates JWTs) | Yes (validates + refreshes) | Yes (validates + refreshes) |
SUPABASE_URL | No | Yes (calls auth endpoints) | Yes (calls auth endpoints) |
SUPABASE_PUBLISHABLE_KEY | No | Yes (auth API requests) | Yes (auth API requests) |
AUTH_CALLBACK_URL | No | Yes (OAuth redirect) | No (no OAuth) |
| Database access | Already has it | Needs it (new) | Needs it (admin lookup) |
Important: When Supabase is enabled, the ClientUI needs direct database access for user lookups, membership checks, and invite acceptance. The InternalUI needs it for global admin verification. Add PG_* environment variables to both services.
Supabase Project Setup
- Create project at supabase.com or self-host
- Get credentials: Settings → API → Project URL, publishable key, JWT secret
- Configure OAuth providers (optional): Authentication → Providers → Enable GitHub/Google/etc. ClientUI auto-detects enabled providers on startup via
GET /auth/v1/settings— only enabled providers will show OAuth buttons on the login and signup pages. - Set redirect URL: Authentication → URL Configuration → Redirect URLs → Add
https://www.planekeeper.com/auth/callback - Disable email confirmation (optional): Authentication → Settings → Confirm email → Off (for faster onboarding)
Docker Compose Changes
The clientui service needs additional environment variables when Supabase is enabled:
clientui:
environment:
# Existing
- SERVER_ADDRESS=0.0.0.0:3000
- CLIENT_UI_API_BASE_URL=http://api:3000/api/v1/client
# Supabase Auth (add these)
- SUPABASE_URL=${SUPABASE_URL:-}
- SUPABASE_PUBLISHABLE_KEY=${SUPABASE_PUBLISHABLE_KEY:-}
- SUPABASE_JWT_SECRET=${SUPABASE_JWT_SECRET:-}
- AUTH_CALLBACK_URL=${AUTH_CALLBACK_URL:-}
- AUTH_COOKIE_SECURE=${AUTH_COOKIE_SECURE:-true}
# Database access (needed for user/membership lookups)
- PG_HOST=postgres
- PG_PORT=5432
- PG_USER=${PG_USER}
- PG_PASSWORD=${PG_PASSWORD}
- PG_DBNAME=${PG_DBNAME}
- PG_SSLMODE=disable
The api service needs the JWT secret to validate bearer tokens:
api:
environment:
# Add to existing vars
- SUPABASE_JWT_SECRET=${SUPABASE_JWT_SECRET:-}
The internalui service needs Supabase and database variables for admin-gated login:
internalui:
depends_on:
api:
condition: service_started
postgres:
condition: service_healthy
environment:
# Existing
- SERVER_ADDRESS=0.0.0.0:3000
- INTERNAL_UI_API_BASE_URL=http://api:3000/api/v1/internal
# Supabase Auth (add these for admin login)
- SUPABASE_URL=${SUPABASE_URL:-}
- SUPABASE_PUBLISHABLE_KEY=${SUPABASE_PUBLISHABLE_KEY:-}
- SUPABASE_JWT_SECRET=${SUPABASE_JWT_SECRET:-}
- AUTH_COOKIE_SECURE=${AUTH_COOKIE_SECURE:-true}
# Database access (needed for global admin lookups)
- PG_HOST=postgres
- PG_PORT=5432
- PG_USER=${PG_USER}
- PG_PASSWORD=${PG_PASSWORD}
- PG_DBNAME=${PG_DBNAME}
- PG_SSLMODE=disable
Note: The Internal UI does not need
AUTH_CALLBACK_URL— OAuth is not supported on the admin interface. Only email/password login is available, gated to superadmin users via Casbing2rules.
User Onboarding Flow
When a user logs in via Supabase for the first time:
- User record is created in the
userstable (linked viasupabase_id) - User is redirected to
/onboarding(no org memberships yet) - User either:
- Creates a new organization → becomes owner
- Accepts a pending invite → joins existing org with invited role
- Active org cookie is set → user reaches the dashboard
Multi-Organization Switching
Users with multiple org memberships see an org switcher in the sidebar. Switching orgs:
- Posts to
/switch-orgwith the target org ID - Validates membership
- Updates the
planekeeper_orgcookie - Redirects to dashboard showing the new org’s data
Maintenance Mode
The Client UI supports planned maintenance mode and automatic outage detection.
Planned Maintenance
Set MAINTENANCE_MODE=true on the clientui service to show a “Scheduled Maintenance” page on all protected routes:
# In .env
MAINTENANCE_MODE=true
# Restart clientui
docker compose -f go/planekeeper/docker/go/planekeeper/docker/docker-compose.yml restart clientui
After maintenance, set MAINTENANCE_MODE=false and restart.
Advance Warning Banner
Before starting maintenance, set an announcement banner from the Internal UI:
- Navigate to Maintenance in the Internal UI sidebar
- Enter a message (e.g., “Database upgrade starting at 2:00 AM UTC”)
- Optionally set estimated duration and severity (Info/Warning)
- Click Set Announcement
The banner appears as a dismissible modal on all Client UI pages within 60 seconds. Clear it from the same page after maintenance completes.
Automatic Outage Detection
The Client UI includes a background health checker that pings the API’s /health endpoint every 10 seconds. If 2 consecutive checks fail (20 seconds), all protected routes automatically show a “Service Temporarily Unavailable” page. Recovery is immediate on the first successful health check.
Typical Maintenance Workflow
| Step | Action | User Impact |
|---|---|---|
| 1 | Set announcement via Internal UI | Users see warning modal (dismissible) |
| 2 | Set MAINTENANCE_MODE=true, restart clientui | Users see maintenance page |
| 3 | Perform maintenance (DB, API, etc.) | Users see maintenance page |
| 4 | Set MAINTENANCE_MODE=false, restart clientui | Normal service resumes |
| 5 | Clear announcement via Internal UI | Warning modal stops appearing |
Timezone (UTC) Enforcement
All timestamps are stored and processed in UTC. This is enforced at three independent layers:
| Layer | Scope | Configuration |
|---|---|---|
Docker Compose command | PostgreSQL server-wide | postgres -c timezone=UTC in both compose files |
| Database-level default | Per-database | ALTER DATABASE planekeeper SET timezone = 'UTC' (migration 035 + init script) |
| Go connection string | Per-connection | &timezone=UTC appended automatically in pkg/config/config.go |
Note: The migration and init script use a hardcoded database name (
planekeeper). If using a non-defaultPG_DBNAME, runALTER DATABASE <your_db_name> SET timezone = 'UTC'manually after deploying migration 035.
Upgrade Notes
Distroless Runtime Images
All runtime images use gcr.io/distroless/base-debian12:nonroot (built via Bazel go_image(), not Dockerfiles). This eliminates all OS-package CVEs (no busybox, no apk, no shell).
Breaking changes:
Container UID changed from 1000 to 65532: The distroless
:nonroottag runs as UID 65532. This affects:- serveragent-git-cache volume: If upgrading from Alpine-based images, the existing volume has files owned by UID 1000. Delete and recreate it:This volume is a disposable git clone cache — no persistent data is lost.
docker volume rm planekeeper_serveragent-git-cache - SSH key bind mounts: If you mount SSH keys for agent credentials, ensure the key files are readable by UID 65532 (or world-readable with
chmod 644).
- serveragent-git-cache volume: If upgrading from Alpine-based images, the existing volume has files owned by UID 1000. Delete and recreate it:
No shell access:
docker exec -it <container> shno longer works. Usedocker logs <container>for debugging. The PostgreSQL container is unaffected (still Alpine-based with shell access).Compose file consolidation:
docker-compose.prod.ymlanddocker-compose.quick.ymlhave been removed. Usego/planekeeper/docker/docker-compose.ymlfor both building and deployment. Thedeploy.shscript automatically migrates from the olddocker-compose.prod.ymlon first deploy.
Dev/Staging Server Setup
The same deploy.sh and go/planekeeper/docker/docker-compose.yml can deploy a non-public dev/staging server that mirrors production. No code changes are needed — the deployment is fully environment-driven.
Prerequisites
- A server on your local network (or reachable via VPN)
- Docker and Docker Compose v2+ installed on the server
- DNS records pointing to the server’s private IP
- The existing Cloudflare API token (same
planekeeper.comzone)
Setup Checklist
1. Create Cloudflare DNS records (DNS-only, grey cloud — no proxy):
| Type | Name | Content | Proxy |
|---|---|---|---|
| A | www.dev | <server-private-IP> | DNS only (grey cloud) |
| A | admin.dev | <server-private-IP> | DNS only (grey cloud) |
DNS-01 challenges work without the server being publicly accessible — Let’s Encrypt validates via TXT records in DNS, not by connecting to the server.
2. Add OAuth callback URL to Supabase (if sharing the same Supabase project):
- Authentication → URL Configuration → Redirect URLs → Add
https://www.dev.planekeeper.com/auth/callback
3. Create .env on the dev server — copy from .env.prod.example and change:
| Variable | Dev Value |
|---|---|
DOMAIN | www.dev.planekeeper.com |
INTERNAL_DOMAIN | admin.dev.planekeeper.com |
AUTH_CALLBACK_URL | https://www.dev.planekeeper.com/auth/callback |
PG_PASSWORD | Dev-specific password |
API_REPLICAS | 1 |
NOTIFICATION_ALLOW_PRIVATE_URLS | true |
NOTIFICATION_BASE_URL | https://www.dev.planekeeper.com |
All other values (Supabase credentials, Cloudflare token, GitHub token) can be the same as production.
4. Deploy:
./docker/deploy.sh user@dev-server-ip --start
5. First user setup — approve yourself and set up admin access:
docker compose -f /opt/planekeeper/go/planekeeper/docker/docker-compose.yml exec postgres \
psql -U planekeeper -d planekeeper -c \
"UPDATE users SET is_approved = TRUE WHERE email = 'your-email@example.com';"
# Grant superadmin via Casbin g2 rule
docker compose -f /opt/planekeeper/go/planekeeper/docker/docker-compose.yml exec postgres \
psql -U planekeeper -d planekeeper -c \
"INSERT INTO casbin_rule (ptype, v0, v1) VALUES ('g2', 'user:' || (SELECT id FROM users WHERE email = 'your-email@example.com'), 'role:superadmin');"
Key Differences from Production
| Aspect | Production | Dev/Staging |
|---|---|---|
| DNS proxy | Cloudflare proxy ON (orange cloud) | DNS only (grey cloud) |
| Network | Public IP, internet-facing | Private IP, LAN/VPN only |
| Replicas | 2+ API, 1+ notifier | 1 of everything |
| Webhook SSRF | Private IPs blocked | Private IPs allowed |
| Remote agents | Can connect via public internet | Cannot reach private IP |
Shared Supabase Considerations
When sharing a Supabase project between prod and dev:
- Authentication identity is shared — same email/password/OAuth works on both
- Local data is independent — each deployment has its own PostgreSQL with separate users, orgs, and jobs
- First login creates a new local user — users must go through onboarding on each environment separately
- User approval is per-environment —
is_approvedmust be set on each database independently - Cookies are automatically isolated (different domain = different cookie scope)
For full details, see the dev server setup plan.
Security Considerations
Internal endpoints are firewall-restricted: The internal Traefik binds to
0.0.0.0:8443and relies on your hosting provider’s firewall to restrict access. Add an inbound rule for TCP 8443 limited to trusted IPs only. The Traefik dashboard (8082) binds to127.0.0.1and is only accessible via SSH tunnel.Use strong passwords: Generate secure passwords for
PG_PASSWORDand other credentials.Rotate API keys: Periodically rotate agent API keys.
Keep images updated: Regularly rebuild images to include security patches.
Firewall rules: Ensure only ports 80 and 443 are publicly accessible.
Database backups: Implement regular automated backups.
Supabase session cookies: Session data is AES-GCM encrypted using a key derived from
SUPABASE_JWT_SECRET. Keep this secret secure — it protects both JWT validation and session cookie integrity.OAuth callback URL: Ensure
AUTH_CALLBACK_URLmatches exactly what’s configured in your Supabase project’s redirect URLs. Mismatches will cause OAuth login failures.