Plane docs

Self-hosting

One container, one Postgres, one hostname.

Plane ships as a single image holding the API server and the control plane. The database is the entire state — agents and their versions, sessions, the event log, providers, connections, signing keys and spans. There is no other durable store and no filesystem state in the container worth keeping.

docker run -p 4010:4010 \
  -e PLANE_DATABASE_URL="postgres://…" \
  -e PLANE_SECRET_KEY="$(openssl rand -hex 32)" \
  -e PLANE_PUBLIC_URL="https://plane.example.com" \
  ghcr.io/plane-dev/plane:latest

The first control API key is printed to the log at boot on a fresh database. Copy it out of docker logs.

Migrations apply while the server builds its storage layer, under an advisory lock, so a first deploy needs no release step and two containers booting together cannot both decide a migration is pending.

The variables that matter

VariableRequiredWhat it is
PLANE_DATABASE_URLyesPostgres. DATABASE_URL is read as a fallback, so a platform's Postgres add-on needs no copying.
PLANE_SECRET_KEYyes32 bytes as hex or base64. Encrypts provider secrets, connection tokens, signing keys and OAuth grants at rest, and signs the control-plane session cookie. Absent, one is generated and warned about, and every secret written in that run is unreadable after a restart.
PLANE_PUBLIC_URLyesThe origin a browser and an authorization server reach you at.
PORTnoDefaults to 4010.
PLANE_AUTH_MODEnorequired (default) or open. open ignores credentials and resolves everything to the default environment — a single-user box, never a shared one.
PLANE_LOGIN_MODEnoworkos, dev or off. dev signs a session for a fixed local user with no identity provider: fine for a one-operator pilot, not fine once the pilot has users.
PLANE_WORKOS_CLIENT_ID / PLANE_WORKOS_API_KEYfor sign-inBoth, or neither. Setting the client id selects workos login and makes dev login a boot error.
PLANE_BOOTSTRAPno1 mints and prints another control key at boot. Unset it again afterwards.
PLANE_MIGRATE_ON_BOOTno0 makes the container touch no schema at all — the release-step shape.
PLANE_SECRET_STOREnolocal (the PLANE_SECRET_KEY envelope) or workos-vault, which keys every secret per organization so an enterprise can hold the root key.
MODAL_TOKEN_ID / MODAL_TOKEN_SECRETfor sandboxesAbsent, every sandbox call fails naming both.
PLANE_SANDBOX_PLANE_URLfor sandboxesWhere a sandbox dials back. It must be reachable from the provider, so it is the public URL and not 127.0.0.1.
PLANE_PREVIEW_DOMAINfor previewsThe wildcard host. Unset disables previews entirely.
PLANE_OTLP_TRACES_URLnoAlso export spans to OTLP. They are persisted and logged either way.
PLANE_SPAN_STOREnopostgres (default) or tinybird. On one node Postgres is right until the spans table is the largest thing in the database.
PLANE_DRAIN_SECONDSnoHow long a SIGTERM waits for running turns before cutting them and releasing their leases. 25, against the 30 seconds most platforms give before SIGKILL.

Model keys are not server variables

Model credentials are BYOK: they are rows in the providers table, encrypted with PLANE_SECRET_KEY, entered through the control plane or providers.upsert. A gateway key in the server's environment would do nothing.

Pick the URL once

PLANE_PUBLIC_URL is not cosmetic. It is the redirect_uri every authorization server compares by exact match, the URL of the client-metadata document that is Plane's client_id where that is supported, the issuer a customer's SDK fetches the JWKS from, and the base of PLANE_SANDBOX_PLANE_URL.

Changing it later means re-registering with every authorization server anybody has connected, and re-pointing every verifier holding the old JWKS URL. Attach the custom domain first, set the variable to it, and only then hand the URL to anyone. The signing keys themselves survive a rename — they are rows — but every place the old URL was written down does not.

Previews

Sandbox previews are served on their own host, deliberately not the API's: a preview serves whatever an agent chose to run, and its own origin is what keeps its cookies, its storage and its service workers out of the control plane's.

*.preview.example.com.   A / AAAA / CNAME   →   the Plane node

plus a certificate whose SAN covers *.preview.example.com. A wildcard covers exactly one label, which is why the preview id and the port share one label with -- between them.

Let's Encrypt issues a wildcard only over DNS-01, so a Caddy in front needs a DNS-provider module and an API token. Behind Cloudflare, Universal SSL already covers one wildcard level. A platform that issues a certificate per custom domain and no wildcards — Railway, for one — serves previews through the edge Worker instead.

Unlike PLANE_PUBLIC_URL, this one is safe to change later: a preview URL is derived from the grant row and the domain on every read rather than stored.

Backups

pg_dump -Fc "$PLANE_DATABASE_URL" > plane-$(date -u +%Y%m%dT%H%M%SZ).dump

Two things a dump does not carry:

  • PLANE_SECRET_KEY. Every provider secret, connection token, signing key and OAuth grant in it is ciphertext under that key. A restore without the key is a restore with no credentials — the rows are there and unreadable. Keep it wherever you keep your other root secrets, not only in the platform's environment editor.
  • API keys already issued. They are stored as SHA-256 digests, so a restore keeps them working and can never show them again.

What one node gives up

  • One replica in the steady state — but two is safe. A turn runs only while its process holds the session's lease, and every append re-checks it, so a rolling deploy that overlaps the old container and the new one is correct. Running many replicas permanently is not the design: every process hears every wake-up and races for the lease.
  • A restart interrupts live turns, and they resume. Boot enqueues a turn for every session the log says has outstanding work, re-issues an open model request with the same request id, re-invokes tools annotated idempotent, and completes the rest as interrupted.
  • Hooks are at-least-once, from an outbox drained by one polling fiber per process.
  • There is no object storage yet. Oversized tool output is truncated at the event log boundary rather than spilled.

On this page