Internal documentation — not for external distribution.

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

ServiceDescriptionReplicas
traefikPublic reverse proxy (SSL termination)1
traefik-internalInternal reverse proxy1
postgresPostgreSQL database1
apiAPI server (runs migrations)2+
clientuiPublic-facing UI1+
internaluiAdmin UI1
taskengineJob scheduler1
serveragentTask executor (co-located with server)1
eolsyncEOL data sync (cron)1
notifierNotification delivery worker1+
docsHugo → Migrated to Hugo on Cloudflare Pages (docs.planekeeper.com)
internal-docsHugo → Migrated to Hugo on Cloudflare Pages (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_URL is 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_KEY is correct
  • Check the API key hasn’t been revoked on the server

Agent gets 404 Not Found:

  • Ensure URL includes /api/v1/internal path
  • 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

URLDescription
https://www.planekeeper.com/Client UI
https://www.planekeeper.com/loginClient login
https://www.planekeeper.com/api/v1/client/*Client API
https://www.planekeeper.com/healthHealth check

Internal Endpoints (port 8443, firewall-restricted)

URLDescription
https://admin.planekeeper.com:8443/Internal service portal (no auth required)
https://admin.planekeeper.com:8443/loginInternal/Admin UI login
https://admin.planekeeper.com:8443/api/v1/internal/*Internal API
https://admin.planekeeper.com:8443/api/v1/internal/metricsPrometheus 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.1 and 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:

RouteServicePurpose
/api/v1/client/*apiClient API endpoints
/api/v1/internal/heartbeat/*apiAgent heartbeat (API key required)
/api/v1/internal/tasks/*apiAgent task polling (API key required)
/api/v1/swagger/*apiClient API documentation
/api/spec/*apiOpenAPI spec files
/healthapiHealth check endpoint
/*clientuiClient UI (catch-all)

Internal Traefik (port 8443)

Routes accessible via direct connection (with firewall rule), SSH tunnel, or VPN:

RouteServicePurposeAuth
/api/v1/internal/metricsapiPrometheus metricsNone
/api/v1/internal/*apiFull internal APIAPI key
/api/spec/*apiOpenAPI spec filesNone
/healthapiHealth check endpointNone
/*internaluiAdmin 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

  1. Go to Cloudflare API Tokens
  2. Click “Create Token”
  3. Use “Edit zone DNS” template or create custom:
    • Permissions: Zone → DNS → Edit
    • Zone Resources: Include → Specific zone → your domain
  4. Copy the token to CF_DNS_API_TOKEN in .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:

VariableDescription
PG_HOST, PG_PORT, etc.Database connection
SERVER_ADDRESSListen address (default: 0.0.0.0:3000)
SERVERAGENT_API_KEYAPI key for server-side agent authentication
GITHUB_TOKENGitHub token for higher rate limits

Notifier Configuration

The notifier service handles webhook notification delivery. Key environment variables:

VariableDescriptionDefault
NOTIFICATION_BATCH_SIZEDeliveries to claim per poll100
NOTIFICATION_POLL_INTERVALHow often to check for work5s
NOTIFICATION_BASE_URLBase URL for acknowledgment callbacks-
NOTIFICATION_ALLOW_PRIVATE_URLSAllow RFC1918/localhost webhooksfalse
NOTIFICATION_MAX_RETRIESMax retry attempts before dead letter12
NOTIFICATION_ACK_TOKEN_EXPIRYAcknowledgment token expiry24h

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/postgresql structure, 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:

  1. Missing full path in CMD: The Dockerfile CMD must use the full path:

    CMD ["/usr/local/bin/supercronic", "/app/crontab"]
    
  2. 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

  1. A Supabase project (cloud or self-hosted)
  2. The project’s JWT secret, publishable key, and URL

Environment Variables

Add these to your .env file:

VariableRequiredDescription
SUPABASE_URLYesSupabase project URL (e.g., https://xxx.supabase.co)
SUPABASE_PUBLISHABLE_KEYYes (clientui)Publishable API key for auth requests (sb_publishable_...)
SUPABASE_JWT_SECRETYesJWT signing secret for token validation (HS256)
AUTH_CALLBACK_URLYes (clientui)OAuth callback URL (e.g., https://planekeeper.example.com/auth/callback)
AUTH_COOKIE_SECURENoSet cookie Secure flag (default: true, set false for HTTP dev)

Which Services Need What

VariableAPI ServerClientUIInternalUI
SUPABASE_JWT_SECRETYes (validates JWTs)Yes (validates + refreshes)Yes (validates + refreshes)
SUPABASE_URLNoYes (calls auth endpoints)Yes (calls auth endpoints)
SUPABASE_PUBLISHABLE_KEYNoYes (auth API requests)Yes (auth API requests)
AUTH_CALLBACK_URLNoYes (OAuth redirect)No (no OAuth)
Database accessAlready has itNeeds 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

  1. Create project at supabase.com or self-host
  2. Get credentials: Settings → API → Project URL, publishable key, JWT secret
  3. 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.
  4. Set redirect URL: Authentication → URL Configuration → Redirect URLs → Add https://www.planekeeper.com/auth/callback
  5. 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 Casbin g2 rules.

User Onboarding Flow

When a user logs in via Supabase for the first time:

  1. User record is created in the users table (linked via supabase_id)
  2. User is redirected to /onboarding (no org memberships yet)
  3. User either:
    • Creates a new organization → becomes owner
    • Accepts a pending invite → joins existing org with invited role
  4. 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-org with the target org ID
  • Validates membership
  • Updates the planekeeper_org cookie
  • 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:

  1. Navigate to Maintenance in the Internal UI sidebar
  2. Enter a message (e.g., “Database upgrade starting at 2:00 AM UTC”)
  3. Optionally set estimated duration and severity (Info/Warning)
  4. 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

StepActionUser Impact
1Set announcement via Internal UIUsers see warning modal (dismissible)
2Set MAINTENANCE_MODE=true, restart clientuiUsers see maintenance page
3Perform maintenance (DB, API, etc.)Users see maintenance page
4Set MAINTENANCE_MODE=false, restart clientuiNormal service resumes
5Clear announcement via Internal UIWarning modal stops appearing

Timezone (UTC) Enforcement

All timestamps are stored and processed in UTC. This is enforced at three independent layers:

LayerScopeConfiguration
Docker Compose commandPostgreSQL server-widepostgres -c timezone=UTC in both compose files
Database-level defaultPer-databaseALTER DATABASE planekeeper SET timezone = 'UTC' (migration 035 + init script)
Go connection stringPer-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-default PG_DBNAME, run ALTER 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:

  1. Container UID changed from 1000 to 65532: The distroless :nonroot tag 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:
      docker volume rm planekeeper_serveragent-git-cache
      
      This volume is a disposable git clone cache — no persistent data is lost.
    • 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).
  2. No shell access: docker exec -it <container> sh no longer works. Use docker logs <container> for debugging. The PostgreSQL container is unaffected (still Alpine-based with shell access).

  3. Compose file consolidation: docker-compose.prod.yml and docker-compose.quick.yml have been removed. Use go/planekeeper/docker/docker-compose.yml for both building and deployment. The deploy.sh script automatically migrates from the old docker-compose.prod.yml on 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.com zone)

Setup Checklist

1. Create Cloudflare DNS records (DNS-only, grey cloud — no proxy):

TypeNameContentProxy
Awww.dev<server-private-IP>DNS only (grey cloud)
Aadmin.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:

VariableDev Value
DOMAINwww.dev.planekeeper.com
INTERNAL_DOMAINadmin.dev.planekeeper.com
AUTH_CALLBACK_URLhttps://www.dev.planekeeper.com/auth/callback
PG_PASSWORDDev-specific password
API_REPLICAS1
NOTIFICATION_ALLOW_PRIVATE_URLStrue
NOTIFICATION_BASE_URLhttps://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

AspectProductionDev/Staging
DNS proxyCloudflare proxy ON (orange cloud)DNS only (grey cloud)
NetworkPublic IP, internet-facingPrivate IP, LAN/VPN only
Replicas2+ API, 1+ notifier1 of everything
Webhook SSRFPrivate IPs blockedPrivate IPs allowed
Remote agentsCan connect via public internetCannot 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-environmentis_approved must 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

  1. Internal endpoints are firewall-restricted: The internal Traefik binds to 0.0.0.0:8443 and 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 to 127.0.0.1 and is only accessible via SSH tunnel.

  2. Use strong passwords: Generate secure passwords for PG_PASSWORD and other credentials.

  3. Rotate API keys: Periodically rotate agent API keys.

  4. Keep images updated: Regularly rebuild images to include security patches.

  5. Firewall rules: Ensure only ports 80 and 443 are publicly accessible.

  6. Database backups: Implement regular automated backups.

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

  8. OAuth callback URL: Ensure AUTH_CALLBACK_URL matches exactly what’s configured in your Supabase project’s redirect URLs. Mismatches will cause OAuth login failures.