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