TurboPanel Docs
Architecture

Instance API architecture

The TurboPanel API lives in the TurboPanel/turbopanel repository at ~/turbopanel. It is a Hono application with dual runtimes: Cloudflare Workers (src/workers.ts + wrangler) and Deno (src/deno.ts + Unix socket).

Versioned surfaces

Four versioned surfaces each have REST + WebSocket namespaces (where applicable). Prefixes live in src/surfaces.ts. GET /api/health is the single unversioned probe.

SurfaceRESTWebSocketAudience
Client/api/client/v1/*/ws/client/v1End-user UI
Developer/api/developer/v1/*/ws/developer/v1Dev console (__DEV__ only)
Install/api/install/v1/*Self-hosted install wizard (Deno only)
Daemon/api/daemon/v1/*/ws/daemon/v1Remote daemons

Unversioned docs:

  • GET /api/openapi.json — OpenAPI 3.1 spec
  • GET /api/reference — Scalar embed

Folder layout (turbopanel/src/)

AreaFilesRole
App shellapp.ts, surfaces.tscreateApp() mounts routers, health, OpenAPI, Scalar
Clientclient-routes.ts, auth/*Sign-in, org servers
Installinstall-routes.ts, auth/install-state.tsSelf-hosted install wizard (/api/install/v1/*, Deno only)
Developerdeveloper-routes-core.ts, developer-routes.tsFleet, database (Deno-only extras in developer-routes.ts)
Daemondaemon-api-routes.ts, daemon-hub.tsDaemon registration, CA fetch, WS hub
Deno-onlydeno.ts, system-routes.ts, dev-sync.ts, tunnel-routes.tsUpgrade, reset-dev, dev-sync push
Databasedb.ts, db/schema.tsDrizzle + postgres.js (prepare: true on Workers/Hyperdrive for cached parameterized reads; prepare: false on Deno/direct Postgres)
Workers entryworkers.tsWrangler main; registers developer-routes-core only

Workers bundle rule: Deno-only modules must not be imported from app.ts or workers.ts — register Deno routes only from deno.ts.

Routing flow

Diagram will load when scrolled into view

In self-hosted production, Caddy terminates TLS and proxies /api/* and /ws/* to the Deno Unix socket. In local dev, Caddy proxies to the Deno Unix socket (default) or wrangler (Workers mode).

Authentication

Custom auth built on the Web Crypto API (no nodejs_compat):

  • Passwords: Argon2id (src/client/authn/password.ts, OWASP m=19456,t=2,p=1)
  • Sessions: opaque DB tokens + signed cookies (src/auth/session-store.ts)
  • TOTP second factor and passkeys on /api/client/v1/auth/* — sign-in can return a pending challenge rather than a session
  • GitHub / Google sign-in on /api/client/v1/auth/oauth/* — the callback is redirect-only (never JSON)
  • Install wizard (Deno only): host PAM via pamtester for bootstrap; superadmin email/password for first org
  • Workers dev: optional public sign-up via TURBOPANEL_IS_SIGNUP_ENABLED

Client auth routes under /api/client/v1/auth/*. Install wizard routes live under /api/install/v1/*. The full route table lives in turbopanel/src/client/authn/AGENTS.md — do not duplicate it here. Operator flows: Accounts and access.

Secret envelopes

Persisted secrets (variables, TLS private keys, principal passwords) use a single root of trust (TURBOPANEL_SECRETS) and the universal at-rest format tpsecret. At delivery the instance re-seals to recipient-bound tpdaemon(serverId, keyId) via resealSecretForDaemon; daemons decrypt only those envelopes — field order is exactly tpdaemon.v<n>.<serverId>.<keyId>.<payload>. Stateless auth challenges use tp2fa (two-factor sign-in), tpwebauthn (WebAuthn ceremony), and tpoauth (OAuth start/callback CSRF state). The client surface encrypts only (optional show-once generate). There are no per-server at-rest keys — a credential is server-agnostic in Postgres and can be delivered to any authorized daemon. Key rotation uses lazy re-seal-on-write plus superadmin POST /api/admin/v1/secrets/reencrypt. Full scheme table and rotation runbook: Security → Envelope grammar.

Database

Single PostgreSQL 18 database per environment. Schema groups:

GroupTables (examples)
Identityuser, account, session, verification, …
Organizationsorganization, teammate, team, invitation, grant, …
Configsetting
Runtimeserver

Schema changes are versioned in instance/migrations/ and applied via pnpm migrate (Workers deploy and co-located dev converge). Deno dev can still use drizzle-kit push (dev/scripts/sync.sh) for quick iteration without committing SQL. See turbopanel/src/lib/db/AGENTS.md.

Compose documents & server placement

project.options.compose / environment.options.compose store a versioned ComposeDocument (version: 1, data, presentation) so YAML comments, blank lines, and section order survive editor round-trips. Deploy strips presentation to runtime YAML and merges the environment overlay onto the project base. The compose library lives at turbopanel/src/lib/compose/.

x-turbopanel vendor extension

TurboPanel reserves a top-level x-turbopanel key. This is a standard Docker Compose x-* vendor extension, so docker compose itself ignores it — the key round-trips untouched through composeDocumentToRuntimeYaml.

FieldPurpose
placement.server_idEnvironment → whole-server pin (UUID)
viewCompose UI tab preference: editor or visual (per project base and per environment overlay)

Environment pinning (today)

placement.server_id is read from the environment overlay compose and pins that environment to one whole server. Different environments in the same project may pin to different servers. The value must be a server UUID in the same organization (validated; unknown or stale ids fail cleanly). Project base compose does not own placement — stale project-level pins are ignored (hard cut) and stripped before merge into runtime YAML.

view is written when the compose Editor or Visual tab is saved. It is hidden from the YAML textarea (like placement) and restored on save; project and environment each keep their own preference.

Deploy resolution

POST /api/client/v1/environments/:id/deploy accepts an optional request body serverId. Resolution order:

Environment pinned?Body serverIdResult
YesomittedDeploy to the pinned server
Yesmatches pinDeploy to the pinned server
Yesdiffers from pin400 server_placement_mismatch
NoprovidedDeploy to the requested server
Noomitted400 (a target is required)

Minimal example

Place this on the environment compose overlay (not the project base):

YAML
x-turbopanel:
  placement:
    server_id: 01989d42-9adb-7e65-bc2e-f38792c53691
services:
  ...

Future: per-service placement

Per-service placement (different services on different servers, Docker Swarm–style deploy compose) is a planned extension of this same x-turbopanel mechanism — the multi-server compose-placement seam noted in the instance/daemon architecture. It is not implemented yet.

Compiled compose (compose.yaml)

The control plane stores a project base compose and an optional environment overlay. Merge and platform inject happen on the instance; deploy then publishes a single compiled compose.yaml (role: 'runtime') that the daemon writes and runs. There is no multi-file -f chain on the host and no composeYaml fallback on environment.deploy.

Overrides and nullification between authored 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.

The organization console's Merged compose preview renders a client-side simulation of the fully merged result — useful for reviewing the effective configuration at a glance — while Prepared compose and the daemon's actual deploy use the compiled compose.yaml. The merged view is a readability aid, not a second execution path.

Daemon hub

Daemons connect to /ws/daemon/v1 and send a hello with hostname, optional persisted serverId, and machineId. The instance resolves a canonical server row, dedupes reconnects, and routes commands over the hub (src/daemon-hub.ts).

Co-located daemons on the same host dial the Unix socket directly (no Caddy hop) and collapse to a single local slot.

Adding new routes

  1. Pick the surface (client, developer, install, daemon).
  2. Add handlers in the matching *-routes.ts file (or auth/http.ts for client auth).
  3. Update src/openapi.ts and ui/src/lib/instance-api.ts when the UI needs the endpoint.
  4. If the route is Deno-only (filesystem, systemctl), register it from deno.ts — never from workers.ts.
Edit on GitHub

Last updated on

On this page