Security
This guide covers comprehensive security best practices for deploying TurboPanel. Securing both the control plane and daemons is essential for production deployments. For setup instructions, see Control Plane and Daemon Setup.
Introduction
TurboPanel's security model spans the control plane (management interface and API) and daemons (remote Docker executors). Both components require attention to authentication, network isolation, and Docker socket access. This guide consolidates security guidelines from both components into a unified reference.
Control Plane Security
Tenant hosting certificates (sites on :80/:443) are separate from the
control-plane leaf. Operators pin a library cert, use Caddy tls internal, or
pin a Let's Encrypt row that Caddy issues on the serving host. See
Hostnames and TLS.
TLS/SSL Configuration
Production Requirement
For production deployments, always use a reverse proxy (nginx, Caddy, Traefik) for HTTPS. Never expose the control plane directly on HTTP in production.
Self-hosted Caddy listens on :8443. Every hostname is served there. The certificate is chosen by the name.
| Source | Listen | Platform CA served | --insecure-tls |
|---|---|---|---|
| Platform CA (default) | :8443 | yes | yes, until the Platform CA is trusted |
| Uploaded | :8443 | 404 for that name | omitted when the certificate chains to a public root |
| Let's Encrypt | :8443 | 404 for that name | no |
Port 80 opens only during Let's Encrypt issuance or renewal. Port 443 is hosting. An apply that finds another process on port 80 fails with port 80 is held by <process>. See Control plane and Hostnames and TLS.
Every control-plane hostname on :8443 (Platform CA, upload, and Let's Encrypt) sends a
baseline response-header set on every response: Strict-Transport-Security
(one year, includeSubDomains), X-Content-Type-Options: nosniff, and
X-Frame-Options: DENY. This is control-plane only — per-tenant hosting sites
are a separate Caddy config the daemon generates and do not inherit these
headers.
Self-hosted deployments ship Caddy on port 8443 with a platform CA by default. You can terminate TLS at an outer reverse proxy instead, proxying to Caddy or the control plane socket:
# nginx example — proxy to Caddy HTTPS or upstream HTTP
server {
listen 443 ssl;
server_name turbopanel.example.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
location / {
proxy_pass https://127.0.0.1:8443;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}ACME issuance failure visibility
A tlsMode: 'acme' tenant hosting relies entirely on Caddy's automatic HTTPS
to obtain and renew a Let's Encrypt certificate on the serving host. The
daemon's AcmeIssuanceObserver live-probes every such hostname every 60
seconds (a fetch() against the system trust store — any HTTP response at
all means the TLS handshake already succeeded against a publicly-trusted
certificate) and reports a state change back to the control plane: a
debounced failure (two consecutive bad polls, so the first few seconds a
fresh deploy spends waiting on a real certificate never false-alarms) or an
immediate recovery. The failure message lands on the certificate's
acme.lastError field and shows as "Issuance failing: …" in the TLS
library screen. This is purely additive — it never changes a managed
row's status, so a recorded failure cannot itself block a deploy that
would otherwise succeed.
Authentication
The control plane implements:
- Signed session cookies for the web UI (
TURBOPANEL_SECRETS) - Argon2id password hashing for credential accounts (OWASP
m=19456,t=2,p=1), with a fixed dummy-hash verify on unknown emails so sign-in response time doesn't disclose which addresses have a local password account - TOTP second factor with one-time backup codes
- Passkeys (WebAuthn, user-verification required, browser-only)
- GitHub / Google sign-in with identity persisted only, no provider tokens, and no automatic linking by email
- Re-authentication (password, or a session younger than 15 minutes) for enroll/disable 2FA, passkey add/remove, and provider unlink
- Optional step-up re-authentication before permanent actions (delete a project, environment, server or managed database, remove a member, revoke a license or server key). An organization owner turns it on; it is off by default and recommended. See Account security
- Host PAM gate for the Deno install wizard (not for routine sign-in)
- WSS registration for daemons at
/ws/daemon/v1(network-layer access control recommended)
Operator flows: Accounts and access.
Initial install endpoint
On Deno self-hosted, install endpoints are only available while the control plane is uninitialized. The wizard verifies host PAM (root or sudo user) then creates a superadmin credential account. Host accounts cannot sign in for routine use — only the superadmin email/password works after install.
Network Isolation
- Use Docker networks to isolate containers
- Restrict Docker socket access to necessary containers only
- Implement firewall rules to restrict access to the control-plane HTTPS entrypoint (8443). Allow 80 from the internet only while Let's Encrypt is issuing or renewing. 443 is hosting
- Use TurboFabric or private networks for daemon communication when possible
- Consider Docker Swarm or Kubernetes network policies
Docker Socket Security
Docker Socket Access
The Docker socket provides full daemon access. Any process with socket access can create, destroy, or modify all containers and images on the host. Use network isolation, authentication, and audit logging.
Mitigation strategies:
- Run control plane in isolated Docker network
- Use Docker socket proxy for read-only access if applicable
- Implement audit logging for Docker operations
- Regularly review and rotate credentials
- Monitor Docker daemon logs for suspicious activity
- Consider Docker context restrictions
The Docker gate narrows this route. It is a root-owned service, turbopanel-docker-gate, that the daemon's converge installs on a managed host wherever Docker runs. A failed gate install only prints a warning: it never stops a Docker install or a converge. It is still in observe mode on every host, so it blocks nothing yet. Enforcement is built but not switched on, and the daemon does not route its Docker traffic through the gate yet (coming in 0.2.x).
What it would block. The gate reads each request the way Docker does and judges it against a strict profile. A request for a container, volume, network or build that would hand out the host is a finding:
- a privileged container, extra Linux capabilities, devices, and the host's network, process, IPC or user namespaces;
- a bind mount of
/, the Docker socket,/etc,/run,/proc,/sys,/dev,/root,/bootor/opt/turbopanel, or of anything outside the site owners' homes (/srv/users) and TurboPanel's own storage; - volume drivers, log drivers and mount types that point somewhere the author picks, and any Docker setting the gate has not reviewed (a field a newer Docker adds is refused until someone reviews it);
- a request whose framing is ambiguous, whose body is not strict JSON, or whose path Docker would rewrite;
- a container request whose owner the gate cannot check (it fails closed when Docker does not answer within 5 seconds).
Platform containers (the database proxy, managed engines, backups) and Compose features a site owner has been approved for are allowed through. The control plane signs each approval for one exact request, and a changed request needs a new one.
Observe and enforce. In observe mode the gate forwards every request to Docker unchanged and writes docker-gate.would-deny to the journal (turbopanel-docker-gate) for each finding, so a host shows what would break before anything does. In enforce mode the same findings are refused with a 403 before Docker sees them, and a docker-gate.denied line is logged. The mode is set in the gate's unit file and is observe on every host today.
Three sockets, under /run/turbopanel-gate/.
| Socket | For | What it allows |
|---|---|---|
docker.sock | The daemon account | Everything the profile allows. Build sessions (/session, /grpc) are a finding here. |
ro/docker.sock | The Traefik ingress proxies | Read only: ping, version, events and container details. Everything else is 403. |
build/docker.sock (stage 4) | The daemon's own image builds | Build sessions open only here. |
Stage 4: builds. A Docker build speaks a stream the gate cannot read, and every build choice (network, mounts, extra privileges) rides inside it. So builds are allowed on one socket only. Only the daemon account may open it: Linux checks the caller when it connects, the socket belongs to root and a group that holds nothing but the daemon account, and no header or token can stand in for it. Site builds that are not Docker builds run in the build sandbox, which has no Docker access at all. The daemon does not use this socket for its builds yet, so enforce mode stays off until it does.
The ingress switch. Without it, each host's Traefik reads container labels through a docker-socket-proxy container that mounts the real Docker socket. With the switch on, Traefik mounts the read-only socket's directory instead, and the proxy container is removed once no service Traefik file on the host still names it. A compromised Traefik then reaches only the read-only endpoints, not the socket proxy (which also passed container logs, export and archive reads). It changes nothing for the daemon, for site and app containers or for the main socket.
The switch is off by default. Its state is one file, /opt/turbopanel/lib/docker-gate/ingress-socket.on, written by the converge. If the file is present and /run/turbopanel-gate/ro exists, the daemon renders Traefik against the read-only socket; if either is missing, it keeps the socket proxy.
To turn it on or off, run the daemon's daemon-converge playbook as root with the role variable docker_gate_ingress_socket set to true or false. Empty, the default, leaves the switch as it is, so an ordinary update never flips it. Any other value fails the converge before anything changes. In both directions Traefik keeps its old wiring until the next ingress apply (the next deploy on that host) re-renders it, and a TCP or UDP service that Traefik rendered before the switch keeps using the socket proxy until it is redeployed.
Proof script. scripts/docker-gate-proof.sh in the turbopaneld repository is the canary check. It is not installed on hosts: run it from a checkout, as root, on a managed host that has Docker, ideally a test host (sudo bash docker-gate-proof.sh). It starts and removes throwaway containers on pinned images and prints PASS or FAIL for each check, and exits non-zero if any fails.
Control-plane container hardening
TurboPanel's own system-compose stack — the Postgres holding every organization's
secrets and TLS keys, and RabbitMQ — runs cap_drop: [ALL],
security_opt: [no-new-privileges:true], a read-only root filesystem (tmpfs for
the one writable path either image still needs at /tmp; everything else each
process writes already lives on its named volume), a memory/pids ceiling
(overridable per install), and a healthcheck reusing the exact readiness
commands (pg_isready, rabbitmq-diagnostics -q ping) the daemon already uses —
so a container that is running but degraded is now visibly unhealthy rather than
indistinguishable from a healthy one. Tenant compose services do not get a
default resource ceiling yet; that default is a separate, larger decision still
open.
Tenant compose field gate
On a self-hosted server, every tenant app on that host shares the same Docker daemon and the same Docker socket the control plane itself talks to. A compose service that uses a host-level feature gets root-equivalent access to that shared host, compromising every other tenant co-hosted on it, and on the documented co-located topology, potentially the control plane's own root secret too. Host-level features are:
- the gated fields
privileged,cap_add,devices,device_cgroup_rules,network_mode,pid,ipc,uts,cgroup,cgroup_parent,userns_mode,security_opt,sysctls,runtime,use_api_socket, andvolumes_from; - any path that reaches outside the service's own directory: an absolute or
~bind (the Docker socket included), a..escape, an interpolated path, adriver_optsbind,configs/secrets/env_filefiles outside the directory, a build context outside it,extends.file, andinclude.
They are deny-by-default for every organization. An organization owner turns them on explicitly (PUT /organizations/:id/compose-privileged-fields). A document that uses one without that opt-in is refused with 403 compose_field_requires_org_opt_in, naming each field and path. With the opt-in on, only an organization manager or owner may deploy such a document (403 compose_host_access_requires_manager). A Git-triggered deploy needs a manager or owner to have deployed the same host-level content once from the app (403 compose_host_access_requires_approval). Relative binds inside the service directory, named volumes, published ports (ports), a non-root user (user), and dropping a capability (cap_drop) are unaffected.
The daemon checks again before docker compose up. It resolves each bind and file source to its real path on the host and refuses one that escapes the deployment directory through a symbolic link, even with the opt-in on. It also refuses a source nested inside another writable bind, where a running container could swap part of the path for a link. The control plane's check reads only the document; this one reads the host.
Every flip of this gate is recorded in the organization's audit trail (organization.compose_privileged_fields.set, readable by owners at GET /organizations/:id/audit), with who did it and when. Turn this on only for an organization with a real, understood need for one of these features. It is not a per-field allowlist: turning it on admits all of them.
Connecting GitHub and GitLab safely
Two rules protect the connection between an organization and its forge.
One GitHub installation, one organization. On an control-plane-wide GitHub App (TurboPanel High Availability, or a self-hosted control plane whose App is shared by every organization), the App's private key can mint a token for any installation — so recording the same installation under two organizations would let the second read the first one's private repositories. The database refuses that outright (uniq_connection_forge_external_github), and the install callback proves the person finishing an install actually approved the installation they are naming: the App requests user authorization during installation, GitHub sends a one-shot code beside installation_id, and TurboPanel checks that the authorizing GitHub user can see that installation before recording anything. The user token is used for that one read and discarded. The callback is also rate-limited per user, so guessing installation numbers is slow as well as pointless.
Apps created through TurboPanel's manifest wizard get this for free. An App registered before this rule — including one you created by hand — needs two settings changed on GitHub (Settings → Developer settings → GitHub Apps → your App): enable Request user authorization (OAuth) during installation, and set the Callback URL to https://<your instance>/api/client/v1/repositories/github/callback (GitHub disables the Setup URL once user authorization is requested; the installation_repositories webhook covers later repository-selection changes). Until then, every install ends with "GitHub did not send an authorization code" in the app.
A forge address must be a public https host. A self-hosted GitLab or GitHub Enterprise address is typed by an organization admin and later fetched by the control plane with credentials attached, so an address pointing back inside the box — 127.0.0.1, 169.254.169.254, a private range, localhost, *.internal — is refused on write (400 forge_url_rejected) and again before every fetch. On the self-hosted control plane the name is also resolved at write time and refused if any answer is a private address. Plain http:// forges are not supported.
Token Management
- Store tokens in environment variables or secrets manager
- Never commit tokens to version control
- Implement token rotation policies
- Use strong, unique tokens for each daemon
- Monitor token usage and revoke compromised tokens
- Use secrets management (Docker secrets, Kubernetes secrets, etc.)
Secret management (at rest vs delivery)
TurboPanel uses a single root of trust — the versioned TURBOPANEL_SECRETS keyring — for session signing, daemon JWT material, and data encryption. There are no per-server at-rest encryption keys.
| Envelope | When | Scope |
|---|---|---|
tpsecret.v<n>.… | At rest in Postgres | Universal format for secret variables, TLS private keys, and principal passwords |
tpdaemon.v<n>.<serverId>.<keyId>.… | At delivery only | Recipient-bound; produced by resealSecretForDaemon just before a daemon receives the secret |
A credential sealed as tpsecret is server-agnostic at rest, so the same stored secret can be delivered to any authorized daemon. Delivery decrypts the at-rest envelope inside the control plane and re-seals it for that daemon's (serverId, keyId); daemons decrypt only tpdaemon envelopes via authenticated POST /api/daemon/v1/secrets/decrypt. Global tpsecret blobs are never handed to daemons.
Encrypt-only client boundary: the client/UI surface seals secrets (encryptSecret / generateSealedSecret) and may show a generated plaintext once. It never decrypts at-rest envelopes for display or reuse.
Envelope grammar
Every TurboPanel-authored serialized secret uses one grammar: <scheme>.v<version>.<fields…>. The embedded version selects the key directly (no trial decrypt). Password hashes are the deliberate exception — they stay standard PHC Argon2id ($argon2id$v=19$m=…) for interoperability.
| Scheme | Shape | Where |
|---|---|---|
tpsecret | tpsecret.v<n>.<payload> | At rest (variables, TLS private keys, principal passwords, email secrets, TOTP secret, OAuth client secrets) |
tpdaemon | tpdaemon.v<n>.<serverId>.<keyId>.<payload> | Daemon delivery only |
tpsession | tpsession.v<n>.<token>.<sig> | Session cookie value |
tpotp | tpotp.v<n>.<hmacHex> | Email OTP verifier at rest |
tpchallenge | tpchallenge.v<n>.<payload>.<sig> | Stateless daemon enroll/auth challenge |
tp2fa | tp2fa.v<n>.<payload>.<sig> | Stateless two-factor sign-in challenge |
tpwebauthn | tpwebauthn.v<n>.<payload>.<sig> | Stateless WebAuthn ceremony challenge |
tpoauth | tpoauth.v<n>.<payload>.<sig> | OAuth start/callback CSRF state |
Keyring (TURBOPANEL_SECRETS): the list is authoritative in the order written — first entry is current/signing; remaining entries are decrypt/verify-only fallbacks. Entries listed out of descending-version order log a warning and the first entry still signs. Example keyring shape (placeholder material only): 2:<new-key>,1:<old-key>.
Rotation (self-hosted runbook)
- Add a key version — re-run the control plane converge with the opt-in extra-var
turbopanel_instance_secret_rotate=true(defaultfalsein the daemoninstance-launchrole; ordinary converges never rotate). The task generates a fresh key via the control planescripts/generate-secret.mjs, computes the next version, and prepends it to/etc/turbopanel/instance/.instance_secrets(root:<turbopanel_group>, mode0640, comma-separated<version>:<value>, highest first). - Re-converge normally — the role slurps the keyring into
turbopanel_instance_secretsand theinstance-deno.dev-vars.j2/instance-workers.dev-vars.j2templates emitTURBOPANEL_SECRETS. Restart picks up the new keyring for the control plane, whose in-process email consumer decryptsMAILGUN_API_KEY/SMTP_PASSwith it. - Confirm new writes use the new version — update any secret variable and check the stored blob now begins
tpsecret.v<new>;GET /api/daemon/v1/jwks.jsonpublishes onekidper keyring version. - Sweep existing rows — Admin → Secrets → Re-encrypt secrets (
POST /api/admin/v1/secrets/reencrypt). Batches are bounded; resume with the returnedcursoruntilcompleted: true. Validtpdaemonblobs are skipped by design; plaintext or malformed blobs are reported asfailedand must be fixed by hand. The sweep covers every table holdingtpsecretmaterial — variables, TLS private keys, principal passwords, storage content, thesecrettable, git forge app envelopes (private key / client secret / webhook secret), GitLab connection OAuth token pairs, the TOTP secret, and the stored auth-provider/email client secrets — so a key cannot be retired before all of it is re-sealed. - Retire the old key — only after the sweep completes and old-key artifacts have aged out: daemon JWTs ≤15 min; daemon enroll/auth challenges (
tpchallenge) ≤60 s; account-security challenges (tp2fa/tpwebauthn) ≤5 min; OAuth state (tpoauth) ≤10 min. Waiting 10 minutes covers every current stateless auth envelope. Note the user-visible consequence: session cookies embed the signing version, so dropping a key signs out anyone whose cookie was issued under it. Retire by editing.instance_secretsto remove the entry and re-converging — and only once nothing is still sealed under it.
Backup and escrow
TURBOPANEL_SECRETS is the single root of trust described above — losing it is permanent, total data loss: every session, every daemon's authorization, and every at-rest secret, TLS private key, and password becomes unrecoverable. Back it up before real data depends on it, not after.
Self-hosted. The keyring lives in exactly one place, /etc/turbopanel/instance/.instance_secrets on the control-plane host, and nothing copies it anywhere by default. Set the instance-launch role's turbopanel_instance_secret_escrow_path extra-var to a path on the machine running the Ansible playbook — never a path on the control-plane host itself, or it isn't actually a backup — and every converge pulls a fresh copy there via ansible.builtin.fetch, including after a rotation. Point it at removable media, an encrypted volume, or wherever your own backup policy already keeps secrets; the task copies the file as-is and applies no additional encryption of its own, the same trust model the file already has on the source host. Left unset (the default), no backup exists and losing the host is unrecoverable.
TurboPanel High Availability (Cloudflare Workers). There is no equivalent automatable step: wrangler secret put is write-only, and Cloudflare has no API to read a secret's value back once set. The value has to be preserved by whoever generates it, at generation time — store it the same way you would any other credential with no recovery path (a password manager, an offline printed copy, your organization's secrets-management tooling) before it is typed into wrangler secret put and the local copy is discarded.
Backup and disaster recovery
The control plane's own Postgres database holds every organization's secrets, TLS private keys, and OAuth tokens — the same "no copy, no recovery" stakes as the root secret above, for a much larger blast radius if it's lost.
Self-hosted. A nightly backup is on by default — postgres_backup_enabled: true in the daemon instance-launch/system-compose role. Every night (postgres_backup_oncalendar, default 03:00) pg_dump -Fc runs inside the database container over its default local connection (no password handling — the same trust model the customer-facing managed-engine backup feature already relies on), writes atomically, and prunes to the newest postgres_backup_retention_keep dumps (default 14) under /var/lib/turbopanel/backup/postgres. That alone protects against operator error and container/data corruption on the same volume, but it is not yet off-host by default — a lost or destroyed control-plane host takes the backups with it just like the database itself. Set postgres_backup_remote_destination to an rsync-compatible target (user@host:/path/) to have the script push each fresh dump off-host immediately after it's written; left unset, backups stay local only.
- RPO (recovery point objective): up to 24 hours — the gap since the last nightly dump. Lower it by running the backup script manually before a risky change, or by shortening
postgres_backup_oncalendar. - RTO (recovery time objective): no fixed target published yet; dominated by restore time, which scales with database size. Time a real restore on your own data before treating any number here as a promise.
Rehearsed, on a real database
A dump-and-restore of a real control-plane database (2026-09-18, a fresh
install: 67 tables, 289 indexes, 749 constraints, 224 KB compressed dump)
took 0.3 s to dump and 2.1 s to restore, and the restored copy matched
the source object-for-object. Those figures are the floor, not a promise:
they scale with your data, and an empty control plane is the smallest case there
is. What the rehearsal does establish is that the mechanism works end to end
— pg_dump -Fc out, pg_restore into a fresh database, everything back.
Rehearse it yourself on a copy before you need it:
# 1. Dump, exactly as the nightly timer does.
docker exec turbopanel-database pg_dump -Fc -U turbopanel -d turbopanel > rehearsal.dump
# 2. Restore into a scratch database — never over the live one.
docker exec turbopanel-database psql -U turbopanel -d postgres -c 'CREATE DATABASE restore_rehearsal'
docker exec -i turbopanel-database pg_restore -U turbopanel -d restore_rehearsal --no-owner < rehearsal.dump
# 3. Compare, then drop the scratch copy.
docker exec turbopanel-database psql -U turbopanel -d restore_rehearsal -c \
'select count(*) from information_schema.tables where table_schema = current_schema()'
docker exec turbopanel-database psql -U turbopanel -d postgres -c 'DROP DATABASE restore_rehearsal'A restore that has never been run is a backup you do not have.
- Restore runbook:
- Stop the stack so nothing writes to the database mid-restore:
systemctl stop turbopanel-system-stack. - Confirm the dump you're restoring from is intact —
pg_restore --list <dump>should enumerate its contents without error before you touch the live database. - Bring the database container back up on its own (
docker compose -p turbopanel-system -f /etc/turbopanel/system/docker-compose.yml up -d database), then drop and recreate the target database:docker exec turbopanel-database dropdb -U turbopanel turbopanel && docker exec turbopanel-database createdb -U turbopanel turbopanel. - Restore:
docker exec -i turbopanel-database pg_restore -U turbopanel -d turbopanel < <dump>. - Restart the full stack (
systemctl start turbopanel-system-stack) and confirm the control plane comes up and can read a known row before considering the restore complete.
- A rotation performed after the dump was taken means the restored database's secrets are sealed under an old key version the running control plane may no longer trust for signing — re-run the rotation runbook's sweep step after a restore if the dump predates a rotation, rather than assuming the restored data is immediately consistent with the current keyring.
- Stop the stack so nothing writes to the database mid-restore:
TurboPanel High Availability (Cloudflare Workers). Workers route through Hyperdrive, a connection pooler in front of an origin Postgres — Hyperdrive itself takes no snapshots and isn't a backup service. What backs that origin, and who owns its backup/restore procedure, isn't settled yet; treat the self-hosted backup story above as the reference until this is documented here.
Firewall Rules
TurboPanel owns the host firewall
The installer removes ufw, firewalld, iptables-persistent and netfilter-persistent from every host it installs on — the control-plane host and every daemon'd server — and disables nftables.service, on every converge. ufw and Docker do not compose (published container ports traverse DOCKER-USER, which ufw's INPUT rules never see), and a host that shipped with ufw active kept the control plane's 8443 closed until an operator opened it by hand. Removal only ever moves the kernel toward accept, so it cannot lock a host out; after it, the host is exactly as open as a fresh image until TurboPanel's own rules arrive. Those rules are derived from what the control plane deployed (the control-plane entrypoint, hosting, ProxySQL, WireGuard, sshd, compose ports:), with organization defaults and per-server rules on top, inbound only — outbound stays open. Each server shows them as a preview today and nothing is applied yet (see Firewall); until enforcement is switched on, the recommendations below are yours to apply outside the host (cloud security groups, a provider firewall) rather than with a host front-end the installer will remove.
Recommended:
- Allow inbound 8443 only from trusted networks. Allow 80 from the internet only while Let's Encrypt is issuing or renewing. 443 is hosting
- Restrict outbound to necessary services only. The control-plane process itself places no outbound restriction on a self-hosted control plane (decided 2026-09-18): earlier builds ran the compiled binary under a fixed list of hosts it could reach, which made every operator-configured destination — an alert webhook, a chat integration, a push relay, a self-managed forge — impossible. That list is gone; the boundary is your host firewall, so shape egress there if the control plane should reach only known hosts. Operator-supplied URLs are still checked at write and at fetch:
httpsonly, no credentials in the URL, no reserved names, a publicly routable address. - Block direct Docker socket access from external networks
- Use fail2ban or similar for brute-force protection
- Implement rate limiting on API endpoints
- For managed databases, treat ProxySQL listeners (5432 / 3306) as the only host SQL ingress — never expose engine containers; keep private replication/backend paths on site or TurboFabric CIDRs. Prefer org-CA
verify-fullclients after downloading the CA PEM. Full contract: Managed database ingress.
Service users (self-hosted)
Managed hosts use dedicated users: tp (UID 9999, daemon + Ansible), tpctrl (UID 9998, instance/UI), and tpcaddy (UID 9993, Caddy). Full UID/GID map (Redis, Postgres, RabbitMQ, optional web-server accounts): instance AGENTS.md — Production UID/GID allocation. Neither should run as root in steady state.
Tenant principal accounts have homes at /srv/users/<username>. The host picks their UID/GID from 15001–60000. An override set on the principal must be ≥ 15001; lower values are refused, and the reserved tp* band 9989–9999 is never used. Accounts from earlier releases keep their IDs. The uninstall purge finds principals by home folder, not by ID. See Purge and data export.
Daemon Security
Authentication
- Daemons register over WSS; restrict who can reach
/ws/daemon/v1and/api/daemon/v1/*at the network layer - Use TLS with the Platform CA (or a public certificate) for all daemon ↔ control plane traffic. A Let's Encrypt name, and an uploaded certificate that chains to a public root, use the system trust store and omit
--insecure-tls. - Rotate the control plane root keyring with the self-hosted Rotation runbook; run the admin at-rest secret re-encrypt sweep before dropping old key versions
Manifests, the Platform CA and secrets on the command line
Signed manifests. The installer (run.sh), the daemon and the control plane verify each channel manifest's Ed25519 signature before they use it, and parse it strictly: a manifest that repeats a key is refused, and every field is read from the exact bytes the signature covered. The daemon and the control plane also refuse a validly signed manifest that is older than the build they already run; set TURBOPANEL_ALLOW_DOWNGRADE=1 in the daemon's environment on the host to have the daemon accept one deliberately (the control plane's own check is not lifted by it). Key handling is in Signing key rotation.
The Platform CA pin. On a self-hosted control plane the installer fetches the Platform CA from GET /api/daemon/v1/instance/ca, pins it as /etc/turbopanel/instance-ca.pem and the daemon trusts it alongside the system roots. An existing pin is never replaced over unverified TLS:
| You pass | What the installer does with the pin |
|---|---|
| Nothing | Fetches the CA verifying against the existing pin. If that fails to connect, it asks once more using only the system trust store: a 404 means the control plane now presents a publicly trusted certificate and the pin is dropped; a PEM that parses and validates the live certificate replaces it. Otherwise it keeps the existing CA, prints the old and new fingerprints, and tells you to confirm the new fingerprint out of band and re-run with --instance-ca. It never retries with verification off. |
--instance-ca <pem> | Installs the PEM you give it as instance-ca.pem and skips the fetch. This is how you apply a real CA rotation. |
--insecure-tls (or TURBOPANEL_INSECURE_TLS=1) | Skips certificate verification (curl -k) for the self-hosted bootstrap fetches only: the installer's own re-exec, the CA fetch and the private uploaded-issuer fetch. The run then stops unless the CA or issuer it fetched validates the certificate the control plane presents. Release and update downloads are always verified. Compare the printed fingerprint with the control plane's before relying on it. |
tp-orchestrate update, the root helper behind a panel-driven daemon update, does not accept --insecure-tls, and the only CAs it trusts beyond the system store are /etc/turbopanel/instance-ca.pem and a pinned private uploaded issuer, if one is installed.
Secrets travel in the environment, not on the command line. The installer's license and tunnel token reach its sudo re-run through the environment (--preserve-env), and the daemon starts cloudflared with the tunnel token in TUNNEL_TOKEN, so neither shows in ps or /proc/<pid>/cmdline. The daemon itself can read only TURBOPANEL_*, HOME, PATH, USER and LOGNAME from its environment, and the control plane only TURBOPANEL_*, HOME, PATH, LANG, CADDY_TLS_CERT and SMTP_PORT.
Network Security
Production Requirement
Use HTTPS/WSS for production deployments. Never use plain HTTP/WSS for daemon-to-control-plane in production.
- TLS/SSL: Use HTTPS/WSS for production deployments
- Network Isolation: Run daemons in isolated networks when possible (Docker networks, VPC)
- Firewall Rules: Daemons are outbound clients — block unnecessary inbound to daemon hosts; allow outbound 8443 from daemons to the control plane
Docker Socket Security
- User Permissions: Daemon runs as non-root user (
tp:tp, UID 9999) - Socket Binding: Only bind socket to necessary containers; avoid exposing socket to other services
Best Practices
- Use strong, unique tokens for each daemon
- Enable TLS/SSL in production
- Monitor daemon connections and activity (logs, metrics)
- Implement rate limiting on control plane for daemon endpoints
- Regularly rotate authentication tokens
- Use network policies to restrict daemon communication to control plane only
Security Layers Overview
Secure Deployment Example
# Self-hosted: Caddy on 8443. Every hostname is served there.
# A Let's Encrypt or publicly trusted upload omits --instance-ca / --insecure-tls.
# Remote managed server — the one installer, pointed at your control plane.
# The platform CA is fetched from GET /api/daemon/v1/instance/ca and pinned;
# pass --instance-ca <path> to supply the PEM yourself instead.
curl -fsSL turbopanel.sh \
| TURBOPANEL_LICENSE=<license> \
TURBOPANEL_HOST=https://turbopanel.example.com:8443 sh# Remote managed server connecting to TurboPanel High Availability
# Hosted enrollments use the installer manifest default.
# Set TURBOPANEL_HOST only when you have a documented custom hosted URL.
curl -fsSL turbopanel.sh | TURBOPANEL_LICENSE=<base64url-encoded-license> shDaemon hardening
Daemons dial outbound to the control plane — no inbound listener is required on managed
server hosts for control-plane connectivity. Mount the Docker socket read-only (:ro) only when the daemon truly
needs read-only inspection; orchestration requires full socket access.
Related Documentation
- Control Plane — Installation and configuration
- Daemon Setup — Daemon deployment for both modes
- API Reference — OpenAPI browser and authentication routes
Last updated on
First-run tier catalogue
The one-time procedure that gives a fresh TurboPanel High Availability control plane its S1–S7 catalogue — which Stripe products to create, what the verifier checks, and what an empty control plane looks like
Deployment Troubleshooting
Troubleshoot common TurboPanel control plane and daemon deployment issues