Control Plane Deployment
The TurboPanel control plane is the API. It runs as Deno on self-hosted hosts (Unix socket + Caddy) or on Cloudflare Workers for TurboPanel High Availability. The Expo UI is served by Caddy (static export or dev proxy) or Workers assets.
Private alpha — not yet publicly available
Self-hosted and TurboPanel High Availability control planes are both in private alpha and not yet publicly available. Contributor development (Vagrant + sibling repos) is available for engineers building TurboPanel — not as a production operator install path.
Overview
Purpose
- Centralized API for organizations, servers, auth, and daemon orchestration
- Web UI (Expo web) for operators
- WebSocket hub for remote daemons (
/ws/daemon/v1) - OpenAPI + Scalar per surface:
/api/client/v1/openapi.json+/api/client/v1/reference(client API),/api/admin/v1/openapi.json+/api/admin/v1/reference(superadmin)
Relationship to daemon
On the control plane host, a co-located daemon installs and updates the control plane, Caddy, UI, and Postgres via Ansible. Additional servers run a standalone daemon that connects back over WSS. See Daemon setup.
Co-located daemon
The control plane server includes a co-located daemon that dials the local control plane (socket or Caddy).
Remote servers use the production installer at turbopanel.sh — not a separate control-plane container image.
One server is enough
The server that runs the control plane (this server in the app) is an ordinary deploy target.
A single-server install runs the control plane and your projects on the same box; what you give up is
multi-node placement — a second database node, spreading projects across hosts — not the ability
to deploy. Enrol more servers whenever you want that.
Architecture (self-hosted)
Deployment models
| Feature | Self-hosted (Deno) | TurboPanel High Availability |
|---|---|---|
| Runtime | Deno on Unix socket | Cloudflare Workers |
| TLS | Caddy. :8443 is always the Platform CA recovery address. Each published hostname picks platform-ca, an uploaded pair, or Let's Encrypt. | Cloudflare-managed TLS |
| Database | Local Postgres (socket) | Postgres via Hyperdrive |
| UI | Caddy → Expo dev or static export (/opt/turbopanel/share/ui; dev override ../ui/dist) | Workers assets / separate hosting |
| Daemon on CP host | Co-located (socket mode) | Connects to Workers URL |
| Availability | Private alpha — preview | Private alpha — waitlist |
| Pricing (planned) | Free, unlimited servers | See pricing when available |
Control-plane listeners
Self-hosted Caddy listens on :8443. Every hostname is served there, and the certificate is chosen by the name. The Platform CA is still minted, and it stays the catch-all leaf.
The Caddyfile is rendered by the daemon's instance-launch role into /etc/turbopanel/caddy/Caddyfile on every converge. Change names in Admin → Access → Hostnames, then apply. The rendered file is not hand-edited.
| Source | Leaf on :8443 | GET /api/daemon/v1/instance/ca | --insecure-tls |
|---|---|---|---|
| Platform CA (default) | Platform CA leaf, also the catch-all | 200 for that name, and for an unlisted name on this listener | yes, until the Platform CA is trusted |
| Uploaded | the attached pair | 404 for that name | omitted when the certificate chains to a public root |
| Let's Encrypt | leaf copied after HTTP-01 | 404 for that name | no |
| Port | When |
|---|---|
| 8443 | Always. Control plane HTTPS. |
| 80 | Only during Let's Encrypt issuance or renewal. |
| 443 | Hosting only. |
Port 80 is opened on hosting Caddy for that window, then closed. When another process holds it, the apply fails with port 80 is held by <process>. The flow is in Hostnames and TLS.
TURBOPANEL_TLS_PUBLIC=1 is set when any hostname is Let's Encrypt, or when the operator marks TLS public. A name that presents a Let's Encrypt or uploaded leaf gets a 404 from GET /api/daemon/v1/instance/ca. The daemon then uses the system trust store, or the uploaded issuer for a private pair, and the install command omits --insecure-tls for a publicly trusted leaf. An unlisted name on :8443 still receives the Platform CA bundle.
turbopanel_tls_mode is a label derived from the hostname list (self_signed, upload, or lets_encrypt). It does not choose a different port.
Installation paths
Pick the path that matches your goal — they do not substitute for one another. See Installation for the full decision guide.
Contributor development (available today)
For engineers working on TurboPanel source — not production self-hosted or TurboPanel High Availability.
Clone (or fork) the six sibling repos, install Vagrant + a provider, then from the dev checkout:
vagrant up
vagrant ssh
dev/consoleSee Local development.
On a fresh guest the console bootstraps the daemon and converges the stack (optional-services picker). Later launches sit idle until Developer → Converge / re-converge.
On the host, open https://localhost:8443 (ports are forwarded). Prefer a LAN hostname for remote test machines.
Services on a contributor dev host:
| Unit | User | Role |
|---|---|---|
turbopaneld.service | current dev user | Ansible orchestration (daemon from ~/turbopaneld in dev) |
turbopanel-instance.service | current dev user | Deno API on Unix socket |
turbopanel-caddy.service | current dev user | TLS + reverse proxy on :8443 |
turbopanel-ui.service | current dev user | Expo web dev (:8081, dev only) |
See Local development. Logs: journalctl -u turbopanel-instance -u turbopanel-caddy -u turbopanel-ui -u turbopaneld -f
Self-hosted control plane (private alpha — preview)
The same turbopanel.sh installer that enrols a daemon installs a control plane, on an explicit opt-in. It provisions a managed FHS layout on Debian — the bootstrap refuses any other distribution up front — with dedicated service users (tp, tpctrl, tpcaddy), one compiled instance binary, /opt/turbopanel/bin/turbopanel, beside the daemon (its DuckDB library in /opt/turbopanel/lib; the email consumer runs inside it, there is no separate mailer process), the static UI at /opt/turbopanel/share/ui, Postgres / Redis / RabbitMQ / Docker, the platform CA and a self-signed leaf, systemd units and Caddy — from the release packages on GitHub Releases, every asset verified against the release's manifest.json. When the chosen channel has no release yet, the command stops with "does TurboPanel/turbopanel have a <channel> release yet?".
Provision a 64-bit Linux host (Debian 12+ recommended, x86_64 or aarch64), as root or a sudo-capable user.
Install the control plane:
curl -fsSL turbopanel.sh | shRun with nothing else, the installer sets up a control plane on this host. It prints a short welcome (and, on canary or rc, a non-stable-channel warning) then proceeds; on a terminal, Enter continues and q quits. TURBOPANEL_INSTANCE=1 says the same thing explicitly for scripts. Add TURBOPANEL_UPDATE_CHANNEL=rc to install the current release candidate instead of the latest release. The installer takes no license (the wizard issues the first one) and enrols no daemon — to connect this host to an existing control plane, pass TURBOPANEL_LICENSE from that control plane's server install command.
Complete the install wizard at https://<host>:8443 (host PAM + superadmin). A Platform CA name is signed by the certificate at /var/lib/turbopanel/tls/ca.crt. A Let's Encrypt or uploaded name presents that name's own leaf on the same port.
This host's own daemon enrols itself once the wizard has issued the first license; do not run the server installer with a license on it. Enroll every other server with turbopanel.sh and TURBOPANEL_LICENSE — Daemon setup.
The first admin (hosted)
A self-hosted control plane creates its first superadmin in the install wizard, from host PAM.
The TurboPanel High Availability (Workers) control plane has no wizard: signup is off unless
TURBOPANEL_IS_SIGNUP_ENABLED says otherwise, and no route ever grants a role. So a freshly
migrated TurboPanel High Availability database has no way in — and the tier catalogue
needs a superadmin to enter it. Once, from the turbopanel checkout, over the same database URL
pnpm migrate uses:
CLOUDFLARE_ENV=live TURBOPANEL_DATABASE_URL=… pnpm bootstrap:superadmin -- --email you@example.comIt promotes an existing account and nothing else: it refuses when the database already has a
superadmin, never creates a user or sets a password, and — with CLOUDFLARE_ENV set — refuses a
database that is not that environment's Hyperdrive origin. Add --dry-run to see what it would do.
If the account does not exist yet it says so and stops: enable signup on that environment, sign up
as that address, run it again, then turn signup back off. Every later admin is promoted from the
console.
Overview and operator responsibilities: Self-hosted.
Not the contributor Vagrant workflow
Contributor development uses Vagrant and sibling checkouts — see Local development. It is not the production self-hosted install path.
TurboPanel High Availability (private alpha — waitlist)
TurboPanel High Availability runs the control plane on Cloudflare Workers. Remote daemons connect over HTTPS/WSS to the control plane's URL. It is not yet publicly available — join the waitlist for access updates.
When access is available, operators create an organization in the app, configure Workers bindings and secrets, and enroll servers with turbopanel.sh (manifest default host — no TURBOPANEL_HOST required).
Maintainers deploy the Workers bundle from the control plane repo:
export TURBOPANEL_DATABASE_URL="postgresql://user:pass@host:5432/dbname"
# or, for CI / dashboard deploy workflows:
# export DATABASE_URL="postgresql://user:pass@host:5432/dbname"
export CLOUDFLARE_ENV=live # or testing — must match a wrangler.jsonc env name
pnpm install
pnpm deploypnpm deploy runs pending migrations (via TURBOPANEL_DATABASE_URL or DATABASE_URL) then wrangler deploy --env $CLOUDFLARE_ENV --minify. The migration step uses Node only (drizzle-kit migrate + post-migration resource-registry repair) — Deno is not required on the deploy host. Configure Hyperdrive, secrets (TURBOPANEL_SECRETS), and bindings in wrangler.jsonc before deploy.
Daemon Cell on Workers
TurboPanel High Availability coordination uses a hibernation-safe Durable Object per server
(getByName(serverId)) for live WS presence, outbox delivery, and request
correlation — same /api/daemon/v1/* and /ws/daemon/v1 paths as self-hosted.
Self-hosted uses the Redis cell backend instead. UI status reads come from Postgres
only; the cell is not a polling API. See Daemon cell architecture
and instance/AGENTS.md (Daemon Cell) for cost/hibernation rules agents must not regress.
Configuration
The complete list of what a self-hosted control plane reads at boot — required, defaulted, and silently degrading — is Control plane configuration. The most common ones:
Key environment variables (control plane)
| Variable | Purpose |
|---|---|
TURBOPANEL_SECRETS | Session signing (required in production) |
TURBOPANEL_DATABASE_URL | Full Postgres connection URL for self-hosted Deno boot (instance-launch) and tooling |
DATABASE_URL | Tooling-only fallback for pnpm migrate / drizzle-kit when TURBOPANEL_DATABASE_URL is unset (common in CI and dashboard deploy) |
CLOUDFLARE_ENV | Wrangler env name for Workers deploy (e.g. live, testing) — required by pnpm deploy |
TURBOPANEL_SOCKET / TURBOPANEL_SOCKET_DIR | Unix socket path overrides |
TURBOPANEL_UI_MODE | dev (Expo proxy) or static (exported UI) |
TURBOPANEL_UI_ROOT | Static UI export root. Production (FHS) default /opt/turbopanel/share/ui (where the daemon ui-build role publishes the export); co-located dev overrides it to a checkout-relative path such as ../ui/dist |
CADDY_PORT | Contributor dev overlay only (default 8443). The managed Caddyfile binds a literal :8443 and does not read it |
TURBOPANEL_TLS_PUBLIC | When 1 / true, Deno GET /api/daemon/v1/instance/ca 404s for a hostname that presents a Let's Encrypt or uploaded leaf |
TURBOPANEL_IS_SIGNUP_ENABLED | Forces sign-up open (1) or closed (0), overriding the admin setting — both runtimes |
TURBOPANEL_REDIS_SOCKET / TURBOPANEL_AMQP_URL | Redis socket (required, opened lazily) and the RabbitMQ URL for the email queue (unset → probe, then a silent no-op queue) |
TURBOPANEL_UPDATE_CHANNEL | The channel this control plane resolves daemon, control-plane, and UI updates on (release default; trunk, edge, canary, rc) |
TURBOPANEL_UPGRADE_STEP_RETENTION_DAYS | How long a successful upgrade step is kept (default 14). Failed steps stay 90 days; runs stay 365 days, newest 50 kept. See Control plane configuration |
TURBOPANEL_AUTH_PROVIDERS__* | GitHub / Google OAuth client id + secret (GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET). Env wins over the SYSTEM_AUTH_PROVIDERS setting row. On TurboPanel High Availability these are Wrangler secrets. |
Managed hosts inject vars via Ansible instance-launch. Workers dev uses .dev.vars in the control plane checkout. Provider setup (Admin → Sign-in providers, env-wins, and linking rules): Accounts and access.
Admin → Access
/admin opens Access. /admin/networking redirects there. The area is how people and machines reach this control plane. Organization TLS stays on the organization.
| Page | What it sets |
|---|---|
| Hostnames | Every address this control plane answers on, each with a source: Platform CA, an uploaded certificate, or Let's Encrypt. Save & Apply (self-hosted) regenerates the Platform CA leaf and reloads Caddy. The body of the apply is empty — the hostname save already persisted the rows. |
| Certificates | Uploaded pairs (the private key is sent once and never shown again) and this control plane's Let's Encrypt settings: contact email, terms, directory URL, staging. A wildcard is an uploaded pair. These settings are unrelated to any organization's Let's Encrypt opt-in. |
| Trusted proxies | The effective TURBOPANEL_TRUSTED_PROXY_CIDRS list, read-only. A custom list replaces the loopback default. |
| Tunnel | Write-only token for the co-located tunnel. An empty token tears the tunnel down. The API never returns the stored value. |
| Platform CA | Fingerprint, subject, validity, PEM download, and a trust reconcile that asks connected daemons to pick up the current bundle. Self-hosted only. This is the Platform CA. |
Per-hostname sources need a co-located daemon that can render them (instance-cert-sources-per-hostname, daemon 0.1.1 or newer). Older daemons keep the rows visible and leave the sources disabled until that daemon is updated.
Admin → Updates is the managed platform upgrade. On self-hosted it runs this host's daemon, then the control plane, then the other servers, and the other servers wait until the first two are on target. On TurboPanel High Availability the control plane line is Managed by TurboPanel and only daemons roll out. Detail: Upgrade and rollback.
API entrypoints
| Path | Description |
|---|---|
/api/health | Unversioned identity payload — licence, version, commit. It touches nothing, so it is not an outage signal; see What to point a monitor at |
/api/daemon/v1/readiness | The readiness probe. Reads the database; this is the one to monitor |
/api/client/v1/* | End-user REST API + auth |
/api/install/v1/* | Self-hosted install wizard (Deno only) |
/api/developer/v1/* | Developer console (dev tooling) |
/api/daemon/v1/* | Daemon REST (version, instance/ca) |
/ws/daemon/v1 | Daemon WebSocket |
/api/client/v1/openapi.json · /api/admin/v1/openapi.json | OpenAPI 3.1 specs (Scalar at …/reference) |
/ | Web UI (via Caddy) |
HTTPS entrypoint (self-hosted): https://<host>:8443. Every hostname is served there. The certificate is chosen by the name. Trust the Platform CA for a name that presents that leaf.
Communication patterns
- Browser → Caddy → control plane: REST and cookies on
/api/client/v1/* - Daemon → control plane: WSS
/ws/daemon/v1(through Caddy or direct Unix socket when co-located) - Dev sync / tunnel: Operator pushes daemon builds or tunnel tokens via developer API + daemon WS messages
Compose deploys (compiled runtime file)
The control plane stores a project base compose and an optional environment overlay. Merge and platform inject happen on the control plane; environment.deploy then publishes a single compiled compose.yaml (role: 'runtime') that the daemon writes under <stateDir>/deployments/<projectId>/<environmentId>/ next to .env (non-secrets) and deployment.json.
Overrides between the authored project and environment layers follow the Compose Spec merge rules: mapping keys merge recursively (a later layer wins per key), most sequences — ports, volumes, env_file, and similar lists — append with attribute-specific key-based de-duplication rather than replacing the earlier layer wholesale, and command, entrypoint, and a service's healthcheck.test always fully replace rather than append. Authors who need to remove a value entirely, or force full replacement instead of the default append/merge behavior for a sequence, can use the reserved !reset (delete the key) and !override (force full replacement) YAML tags on the environment overlay.
In the app, Merged compose is a client-side simulation of the fully merged effective document (readability aid). Prepared compose and the live environment.deploy path show that compiled compose.yaml. See also API architecture — compiled compose.
Related documentation
- Installation paths — Decision guide for all install paths
- Accounts and access — Two-factor, passkeys, GitHub / Google sign-in, and invitations
- Self-hosted overview — Operator responsibilities
- Daemon setup — Remote server installer
- Security — TLS, auth, and hardening
- Deployment troubleshooting — Runtime issues
- Instance API architecture — Code layout
Last updated on