Skip to main content

Install with Docker Compose

This is the fastest path to a running Planeon control plane: Docker Compose, one environment file, and a handful of commands, start to first login in under 30 minutes.

Prerequisites

  • Docker Engine 24 or newer, with the Compose plugin (docker compose version reports a v2 Compose version).
  • A DNS name that resolves to the host running Planeon, for the web console, API, and gateway origins used below. The example in this guide uses a single origin, vdi.example.com, for all three, routed by path or subdomain on your reverse proxy — adjust to your own domain.
  • A TLS-terminating reverse proxy in front of the web console, API, and gateway. Planeon's containers speak plain HTTP and WebSocket to each other and to the proxy; TLS termination and routing to each service are configured on your proxy, not in this guide. See Requirements → Network paths for the exact ports and protocols each path needs.
  • An existing OIDC identity provider with an application or client already created for Planeon (issuer URL, audience/client ID, and a user you intend to make the first administrator). Planeon delegates authentication to your identity provider and does not ship a bundled one — see What is Planeon for the supported providers.
  • Network access to Docker Hub to pull the public planeon/* images referenced below, or a private registry mirror of them if the install host has no internet access.

If you haven't already, read Requirements for control-plane sizing, browser support, and template OS support before you continue.

compose.yaml

Copy this into compose.yaml in an empty directory on the install host.

name: planeon

services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
env_file: planeon.env
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
# Keep the user/db here in sync with POSTGRES_USER/POSTGRES_DB in
# planeon.env if you change them from the defaults below.
test: ["CMD-SHELL", "pg_isready -U planeon -d planeon"]
interval: 5s
timeout: 3s
retries: 10

redis:
image: redis:7-alpine
restart: unless-stopped
command: ["redis-server", "--appendonly", "yes"]
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 10

# One-shot migration runner. It never starts as part of `docker compose
# up` — it only runs when you explicitly select the "migrate" profile
# (see "Apply database migrations" and "Upgrading" below).
migrate:
image: planeon/migrate:v1.0.0
profiles: ["migrate"]
env_file: planeon.env
depends_on:
postgres:
condition: service_healthy

api:
image: planeon/api:v1.0.0
restart: unless-stopped
env_file: planeon.env
ports:
- "8080:8080"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy

worker:
image: planeon/worker:v1.0.0
restart: unless-stopped
env_file: planeon.env
# Must exceed WORKER_SHUTDOWN_TIMEOUT in planeon.env, so Compose does
# not SIGKILL a job that is still draining gracefully on shutdown.
stop_grace_period: 45s
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy

# Planeon-built guacd with H.264 decoding enabled. The stock community
# guacd image lacks it, which renders modern Windows (10/11, Server
# 2019+) desktop sessions as a black screen with only a live cursor.
guacd:
image: planeon/guacd:v1.0.0
restart: unless-stopped
environment:
# guacd's own daemon log level (trace|debug|info|warning|error).
# Override via GUACD_LOG_LEVEL in your shell or a .env file next to
# compose.yaml; defaults to info. See the configuration reference.
LOG_LEVEL: ${GUACD_LOG_LEVEL:-info}

gateway:
image: planeon/gateway:v1.0.0
restart: unless-stopped
env_file: planeon.env
ports:
- "8081:8081"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
guacd:
condition: service_started

web:
image: planeon/web:v1.0.0
restart: unless-stopped
env_file: planeon.env
ports:
- "3000:3000"
depends_on:
- api

volumes:
postgres_data:
redis_data:

postgres and redis are not published to the host — only the compose network needs to reach them. api (8080), gateway (8081), and web (3000) are published so your reverse proxy can reach them; point its virtual hosts or paths at those three ports and terminate TLS there.

planeon.env

Copy this into planeon.env next to compose.yaml, then replace every placeholder before starting the stack.

# ---------------------------------------------------------------------------
# Deployment label. Informational only.
# ---------------------------------------------------------------------------
APP_ENV=production
LOG_LEVEL=info

# ---------------------------------------------------------------------------
# PostgreSQL — Planeon's system of record. The first two blocks below
# configure the bundled postgres container; DATABASE_URL is how every
# Planeon service connects to it, so keep the credentials in both places
# in sync.
# ---------------------------------------------------------------------------
POSTGRES_DB=planeon
POSTGRES_USER=planeon
# Generate with: openssl rand -base64 32
POSTGRES_PASSWORD=change-me

# Must match POSTGRES_USER/POSTGRES_PASSWORD/POSTGRES_DB above. "postgres"
# is the in-network service name from compose.yaml — leave it as-is unless
# you rename that service.
DATABASE_URL=postgres://planeon:change-me@postgres:5432/planeon?sslmode=disable
DATABASE_MAX_CONNS=10

# ---------------------------------------------------------------------------
# Redis — job queue, caching, and short-lived desktop connection tokens.
# ---------------------------------------------------------------------------
REDIS_ADDR=redis:6379
REDIS_PASSWORD=
REDIS_DB=0

# ---------------------------------------------------------------------------
# API service.
# ---------------------------------------------------------------------------
API_ADDR=0.0.0.0:8080
API_SHUTDOWN_TIMEOUT=10s
# Baked into the planeon/api image's filesystem layout — leave as-is.
OPENAPI_PATH=/usr/local/share/proxmox-vdi-admin/openapi.yaml
# Public origin of the web console, reached through your reverse proxy.
API_CORS_ALLOWED_ORIGINS=https://vdi.example.com
# Internal ops listener (Prometheus /metrics, /healthz, /readyz) for api
# and worker. Do not publish this through your reverse proxy.
METRICS_ADDR=0.0.0.0:9090

# ---------------------------------------------------------------------------
# OIDC — Planeon authenticates users and administrators through your
# existing identity provider. Protected API routes return 503 until both
# AUTH_OIDC_ISSUER_URL and AUTH_OIDC_AUDIENCE are set.
# ---------------------------------------------------------------------------
AUTH_OIDC_ISSUER_URL=https://idp.example.com/application/o/planeon/
AUTH_OIDC_AUDIENCE=planeon
AUTH_OIDC_GROUPS_CLAIM=groups
AUTH_OIDC_ROLES_CLAIM=roles

# First-admin bootstrap (see "First login" below): this address is granted
# the platform_admin role automatically the first time it signs in. Use
# only for first access or break-glass recovery.
AUTH_BOOTSTRAP_ADMIN_EMAILS=admin@example.com
AUTH_BOOTSTRAP_ADMIN_SUBJECTS=
AUTH_BOOTSTRAP_OIDC_GROUP_BINDINGS=

# ---------------------------------------------------------------------------
# Web console — browser-facing OIDC client configuration. The planeon/web
# image applies these NEXT_PUBLIC_* values at container start, so a change
# here takes effect on the next `docker compose up -d` — no image rebuild.
# ---------------------------------------------------------------------------
NEXT_PUBLIC_API_BASE_URL=https://vdi.example.com
NEXT_PUBLIC_OIDC_AUTHORITY=https://idp.example.com/application/o/planeon/
NEXT_PUBLIC_OIDC_CLIENT_ID=planeon
NEXT_PUBLIC_OIDC_SCOPE=openid profile email groups offline_access
# Leave empty to use your identity provider's standards-compliant
# end_session_endpoint from OIDC discovery. Set explicitly only if your
# provider needs a dedicated logout/invalidation URL instead.
NEXT_PUBLIC_OIDC_LOGOUT_URL=

# ---------------------------------------------------------------------------
# Gateway — browser desktop tunnel.
# ---------------------------------------------------------------------------
GATEWAY_ADDR=0.0.0.0:8081
GUACD_ADDRESS=guacd:4822
# Public WSS URL of the gateway through your reverse proxy. Returned to
# the browser by the api in the connection-token response.
GATEWAY_PUBLIC_WS_URL=wss://vdi.example.com/tunnel

# ---------------------------------------------------------------------------
# Secrets at rest — encrypts stored Proxmox cluster tokens, guest
# credentials, and Guacamole connection tokens. Required. Without it, the
# gateway refuses to start (it needs the key to redeem connection tokens);
# api and worker start in a degraded mode while no encrypted data exists
# yet (no secrets can be stored), and refuse to start once it does. Same
# value on api, worker, and gateway.
# ---------------------------------------------------------------------------
# Generate with: openssl rand -base64 32
SECRETS_MASTER_KEY=change-me
# Only set during a key-rotation window; leave empty otherwise.
SECRETS_MASTER_KEY_PREVIOUS=

# ---------------------------------------------------------------------------
# Worker — pool reconciliation and job processing. Values below match the
# built-in defaults; a first install can leave them as-is.
# ---------------------------------------------------------------------------
WORKER_POLL_INTERVAL=1m
POOL_RECONCILE_INTERVAL=30s
POOL_RECONCILE_BATCH_SIZE=50
GUEST_AGENT_WAIT_TIMEOUT=10m
WINDOWS_READY_WAIT_TIMEOUT=30m
WORKER_LEASE_DURATION=5m
WORKER_SHUTDOWN_TIMEOUT=30s
AUDIT_RETENTION_MONTHS=0
AUDIT_MAINTENANCE_INTERVAL=24h

Your Proxmox VE cluster itself is not configured through planeon.env — you connect it from the web console after first login, which is where the quickstart picks up.

The configuration reference describes every variable above — default, whether it's required, whether it's a secret, and what it does.

Install

1. Pull the images

docker compose pull

2. Apply database migrations

Migrations are a separate, explicit step — Planeon never applies them automatically when a container starts. That keeps schema changes visible and auditable, avoids multiple replicas racing to migrate at the same time, and makes sure a failed migration stops the rollout before the new version starts serving traffic.

docker compose --profile migrate run --rm migrate

3. Start the platform

docker compose up -d

This starts postgres, redis, api, worker, guacd, gateway, and web. migrate stays stopped — it only ever runs when you explicitly pass --profile migrate. Confirm every service is Up, with postgres and redis additionally showing (healthy):

docker compose ps

If a service won't start or stays unhealthy, see Troubleshooting.

4. First login

Open the web console at the public origin where you exposed the web service (in this example https://vdi.example.com) and sign in through your identity provider.

Planeon has no built-in local password store — administrator access starts from the bootstrap mechanism: the address you set in AUTH_BOOTSTRAP_ADMIN_EMAILS (or, if you used it instead, AUTH_BOOTSTRAP_ADMIN_SUBJECTS) is granted the platform_admin role automatically the first time that identity signs in, with no manual database edit required. Reserve this bootstrap variable for first access or break-glass recovery; once you're signed in, grant further administrators through the access management screens instead of adding more bootstrap addresses.

Once you're signed in as the platform administrator, continue with the quickstart to connect your Proxmox cluster, prepare a template, and build your first desktop pool.

Upgrading

Upgrades follow the same explicit ordering as the install, plus a restart:

# 1. Edit the image tags in compose.yaml (and add any new variables to
# planeon.env) for the release you're upgrading to.
docker compose pull
docker compose --profile migrate run --rm migrate
docker compose up -d

Order matters: migrations always run before the new images start serving, never after and never automatically. Running new code against a not-yet-migrated schema, or old code against a schema that has already moved ahead, is exactly the mismatch the api's and worker's readiness check is built to catch — see Observability → Readiness semantics. Keeping the migration step explicit and separate is what avoids relying on that gate under real user load in the first place.