Writing compose
A TurboPanel project is described by a Compose file. Not a dialect of one: the Compose Specification as published, validated against the same schema Docker uses, with one reserved extension — x-turbopanel — for the handful of things Compose has no way to say. If you can write a compose.yaml, you can write a TurboPanel project. This chapter is the reference for that document: the two editors that produce it, what the platform reads, what it adds, what it refuses, and how an environment's overlay merges over it.
Three rules
Everything else on this page follows from three rules the platform holds itself to.
- Compose says what the workload wants. Image, command, ports, volumes, networks, health checks,
deploy.replicas,deploy.resources,deploy.placement— if the Compose Specification has an expression for it, that is the expression TurboPanel reads. There is never anx-key for a question Compose already answers. x-turbopanelsays only what Compose cannot. One namespace, two places: the document root (principals) and each service (serviceKind,principal,hosting,source,php,cron, and a few runtime pins). A privilege decision — a Unix uid, a home directory, a password — never belongs in a document at all.- The platform decides how. Which server, which container name, which Docker network, which address. A document never pins those; one that tries is refused rather than quietly honoured.
One document, three views
The project editor shows the document through three lenses, and they are views of one artifact, not three files:
| Tab | What it shows | Edit here when |
|---|---|---|
| Compose | The YAML, with comments, blank lines and key order preserved across saves. A linter runs on every keystroke. | You know the file you want. |
| Services | The same document as service cards — each service's image or build, ports, volumes, depends_on, and a form for the x-turbopanel fields. | You would rather fill in How is this served? than remember the key. |
| Overview | The topology drawn from the document plus live facts: hostnames above the services, the server frame around them, running status once deployed. | You want to see what a change does before saving. |
An unsaved draft follows you between the three tabs. Save and Discard appear only while the draft is dirty; Discard is two presses and returns to the last saved document. On the Overview a dirty draft shows the Proposed topology with a Proposed | Saved toggle.
The x-turbopanel blocks are hidden from the YAML view and re-attached on save, so the Compose tab shows plain Compose and the Services forms own the extension fields. Nothing about the stored document or its validation changes between the two.
Before you begin
- A project of the compose type — every card on the create wizard except Template and Managed produces one, and a template project becomes an ordinary compose project once created.
- For a service that runs as a Unix account — a site or a Node app — a system user declared under
x-turbopanel.principalsor assigned on the Bindings tab. The daemon skips a release that has nobody to run as, and the app keeps Deploy disabled until one is assigned. - For a service built from a repository, a Git source connected under Projects → Git sources.
Add a service
Open the Services tab and add a service. Give it a name — it becomes the Compose service key, so it follows Compose's rules ([a-z0-9._-]). The name lives in the document: to rename a service, rename its key here, never on the service's own settings page.
Choose How is this served? — a Container image, a site behind Caddy, nginx, Apache or OpenLiteSpeed, or a Node app. This sets x-turbopanel.serviceKind and decides which fields the form offers next.
Fill in what that kind needs: image, tag and registry (or an inline Dockerfile) for a container; a document root and PHP options for a site; a framework hint and Node version for a Node app.
Optionally attach a repository under Source (application → account → repository, then a branch), add scheduled jobs, set a restart policy, and give the service a description.
Save. The linter's findings appear inline; a blocking finding disables Save until it is fixed, an advisory one does not.
The three kinds
serviceKind | Runs as | Needs | Compose image/build |
|---|---|---|---|
container (default) | A Docker container | An image or a build | Required |
site | A host web engine (Caddy by default; engine picks nginx, Apache or OpenLiteSpeed) serving a directory under a Unix account | A principal alias; optionally root and php | Refused — a site has no image |
node | A Node process supervised by a systemd unit under a Unix account | A principal alias and a source; optionally framework and nodeVersion | Refused — the process is built from the source |
Sites and Node apps are stripped out of the runtime compose the daemon runs; their releases are checked out, built and promoted under the account's home, and the host engine or the unit serves them. Everything else in the document — networks, volumes, the other services — deploys as ordinary Compose.
Sites: engines, files and users
A site service is served by one engine on the server, chosen with engine. Each engine runs under its own service account and serves files on the loopback address; the host's hosting Caddy fronts it.
engine | Engine account |
|---|---|
caddy (default) | tpcaddysite |
nginx | tpnginx |
apache | tpapache |
openlitespeed | tpols |
There is no separate nginx-with-PHP engine: give a site a php block to run PHP. How that PHP is started (FastCGI, php-fpm or lsphp) is the site's PHP mode, and Apache never uses mod_php.
Who runs what. The engine process runs as its engine account and only reads the site's files. The files belong to the site owner's Linux user (the principal alias in the document), with the engine's group allowed to read. PHP runs as that same Linux user, so what a site writes, it writes as its own account. A Node app is a systemd unit that runs as its owner's Linux user. A Railpack service has no Linux user of its own: it is an ordinary container. These users are created on first deploy; the host picks their UID and GID from 15001 to 60000 (an override must be 15001 or more), with homes under /srv/users/<username>. A site and a Node app each need exactly one owner; see site_principal_ambiguous.
Where the content comes from. A site has one of two sources:
sourceKind | Content | Writable by the site's account |
|---|---|---|
release (default) | A release built from the service's source. The site serves sites/<service>/current. | No. Releases are sealed read-only and owned by root. |
managed-directory | sites/<service>/webroot/, created for the site owner's Linux user the first time, with a placeholder index.html only while it is empty. | Yes. You upload over SFTP. |
A managed directory gives up the read-only guarantee: the tree the engine executes is writable by the account that runs it. That is the right trade for WordPress and other PHP applications that write to themselves, and the wrong one for a built application. It needs a site owner's Linux user (site_managed_directory_unowned otherwise), PHP is still confined to the site's own directory by open_basedir, and connecting a repository to the site later is a field change rather than a move: remove sourceKind and add source.
Import a docker run line
Import docker run (in the compose editor toolbar) turns a docker run … command into one ordinary service — plain Compose, nothing written under x-turbopanel, validated like a hand-written document. The importer knows every option docker container run accepts; the ones Compose can express are mapped, the ones that describe the container but have no Compose expression are refused with the option named (docker_run_unsupported), and a gated field the command asks for (--privileged, --cap-add, a Docker socket mount) is flagged as a risk before the service exists. Shell syntax in the pasted line — $(…), pipes, redirection, globs — is taken as literal text and warned about, never executed.
The x-turbopanel extension
At the document root
x-turbopanel:
principals:
web:
access: sftp # none | sftp | ssh — the access the account is created withprincipals declares the system users a document's services may run as, by a document-local alias. The alias is a name inside this file; the Unix account behind it — username, uid, home, keys, password — lives on a system user row the platform creates on first deploy and that you manage on the Bindings tab. An alias is never a Linux username. The account's system name follows the organization's name scheme (default partial).
On a service
services:
shop:
image: ghcr.io/example/shop:2.4
x-turbopanel:
description: Storefront
hosting:
- hostname: shop.example.com
targetPort: 3000
forceHttps: true
tls: { mode: acme }
bind: { scope: public }
cron:
- name: reindex
schedule: "0 3 * * *"
command: node scripts/reindex.js| Key | Kinds | Meaning |
|---|---|---|
serviceKind | all | container (default), site, node. |
description | all | Free text shown on the service card and the Overview. |
principal | site, node (refused on container) | Alias of a root principals entry — the account the service runs as. Required for sites and Node apps. |
hosting[] | all | Hostnames and ports the service answers on. Each entry: hostname, optional pathPrefix, targetPort, forceHttps, tls: { mode: internal | certificate | acme, certificateRef? }, bind: { scope: public | datacenter | local, ipRef? }. Materialized into hosting rows you see on the Hosting tab; rows declared here are read-only there. |
source | all (required on node) | { sourceId, branch?, subdirectory?, buildCommand?, startCommand?, outputDirectory?, buildKind?, deployOnPush? }. The repository and branch to check out. startCommand is honoured only on node. buildKind is native (the default) or railpack; railpack builds a container image from the source and is valid only on a container service (see Releases and rollback). deployOnPush: false keeps a git push from deploying the service (an app deploy still does). An environment's own document can override branch and deployOnPush; see Git. |
engine | site | caddy (default), nginx, apache, openlitespeed. |
sourceKind | site | release (the default: the site serves a built, read-only release of its repository) or managed-directory (a writable webroot/ you fill over SFTP; see Sites: engines, files and users). Refused together with a source. |
root | site | Document root, relative to the account's site directory. |
php | site | { version?, mode?, extensions?, settings?, pool? } — a series (8.3, 8.4), the PHP mode (fastcgi, fpm, lsphp-detached; lsphp-attached is accepted but runs as detached), opt-in extensions (loaded host-wide for that series), php_admin_value directives, php-fpm pool tuning. |
framework | node | auto (default), node, next. |
nodeVersion | node | A pin such as 24 or 24.17.0, never a range. |
cron[] | all | Scheduled jobs: name (lowercase, [a-z0-9-], unique within the service), schedule (five-field cron or @daily-style), command (argv — no shell, so >> and ` |
A key that names a privilege — uid, gid, home, shell, keys, password — is refused with a message pointing at the row that owns it.
What is not in the extension, on purpose
| You want | Where it lives |
|---|---|
| Which server the environment runs on | The environment's pin on the Hosting tab. x-turbopanel.placement is refused at save. |
| Replicas and placement constraints | Plain Compose: deploy.replicas, deploy.placement.*. |
| A resource ceiling | Plain Compose: deploy.resources.limits, mem_limit, cpus. |
| A network that spans servers | Plain Compose: networks.<name>.driver: overlay. TurboFabric carries it. |
| Container names | The project's Keep original container names setting. |
| Variable and secret values | The variable store, referenced from the document as ${KEY} — never written into it. |
Field policy — what happens to each Compose key
Every Compose key carries one of five verdicts. Nothing is dropped in silence: a key the platform does not act on is reported with the reason.
| Verdict | Meaning | Examples |
|---|---|---|
| passthrough | Copied to the runtime compose as written. | image, command, ports, volumes, environment, healthcheck, restart, mem_limit, cpus. |
| interpreted | The platform reads it and acts on it; it may or may not reach the runtime file. | deploy.replicas, deploy.mode, deploy.placement.*, deploy.restart_policy, deploy.resources.limits, networks.<name>.driver: overlay. |
| runtime-generated | The platform writes it at deploy; an authored value is not the source of truth. | The placement the compiler records in the prepared compose. |
| unsupported | No behaviour behind it. Saving is advised against; deploying is refused with the reason quoted. | deploy.update_config, deploy.rollback_config, deploy.endpoint_mode, deploy.resources.reservations; on an overlay network: ipam, driver_opts, attachable, enable_ipv6, internal. |
| gated | Implemented, but grants root-equivalent access to the shared host, so an organization owner must opt in first. | privileged, cap_add, devices, device_cgroup_rules, network_mode, pid, ipc, uts, cgroup, cgroup_parent, userns_mode, security_opt, sysctls, runtime, use_api_socket, volumes_from. |
Host-level features
A gated key is one of two kinds of host-level feature. The other is a path that reaches outside the service's own directory on the server:
- a bind whose source is absolute (
/,/etc,/var/run/docker.sock), starts with~, climbs out with.., is interpolated (${…}), or mounts the service directory itself (.) - a long-form mount of type
bind,npipe, or a type the platform does not recognise - a top-level volume whose
driver_optsbind a host path or device configsorsecretswith afile:outside the service directory, andenv_fileorlabel_fileoutside it- a
buildcontext, Dockerfile or additional context outside the service directory,build.ssh, ahostbuild network, a privileged build, or build entitlements extends.fileandinclude, wherever they point
A relative bind that stays inside the service directory (./data:/data, ./config:/etc/app:ro), a named volume, an NFS volume, and a remote build context (a Git URL) are ordinary features and need nothing.
Host-level features are off by default, per organization. An organization owner turns them on under Manage Organization → Compose (PUT /organizations/:id/compose-privileged-fields); every flip is written to the organization's audit trail. With them on, only an organization manager or owner can deploy a document that uses them.
A Git-triggered deploy has no person behind it. It may deploy host-level content only after a manager or owner has deployed that same content once from the app. That deploy records an approval tied to exactly what reaches the host, so any change to those parts of the document needs a new console deploy before automated deploys resume.
The server checks again at deploy. It resolves every bind and file path to its real location on the host and refuses one that escapes the service directory through a symbolic link, even with host-level features on. It also refuses a path nested inside another writable bind, which a container could change between the check and the deploy. The deploy then fails with compose deploy refused — host paths outside this deployment: followed by each path and where it resolves.
cap_drop, ports and user are deliberately not gated.
The linter
The linter runs in the editor on every keystroke and again on every save and deploy. Save refuses errors; deploy refuses errors and treats some advisories as errors.
| Code | Severity | Fires when |
|---|---|---|
| structural | error | Unknown top-level or service key (with a did you mean), a container service with neither image nor build, an invalid value against the Compose schema. |
field_unsupported | advisory at save, error at deploy | An unsupported key from the table above. |
field_requires_org_opt_in | advisory | A gated key. The deploy itself is refused unless the organization opted in. |
field_recommends_resource_limits | advisory | A container service with none of mem_limit, cpus or deploy.resources.limits. An organization may set a default ceiling that fills the gap at deploy. |
turbofabric_required | error at deploy | Services on more than one server share a network that is not driver: overlay. |
| unresolvable alias | error | x-turbopanel.principal names an alias the root principals map does not declare. |
| unresolvable source | error | x-turbopanel.source.sourceId names a repository the organization does not have. |
Merging an overlay
An environment's overlay merges over the project compose per the Compose Specification. Preview ▾ → Merged compose shows the result.
| Attribute | Rule |
|---|---|
ports, volumes, secrets, configs, expose, extra_hosts | Append; duplicates removed by their key (a port's published side, a volume's target). |
dns, dns_search, tmpfs, env_file and other plain lists | Append, duplicates kept. |
environment, labels, depends_on | Normalized to maps, then merged by key; the overlay's value wins. |
command, entrypoint, healthcheck.test | Replaced whole — never appended. |
| Everything else | The overlay's value replaces the project's. |
Two YAML tags change the rule for one key:
services:
api:
ports: !override
- "8443:443" # replaces the project's list instead of appending
labels: !reset # removes the key entirelyVariables in the document
Reference a variable as ${KEY} or ${scope.KEY}. Non-secret values are written to a generated .env beside the runtime compose; secret values never touch YAML — they compile to Compose secrets: with a file path the daemon materializes at deploy, plus a courtesy KEY_FILE variable holding that path. A binding to a managed database contributes its own locked variables under the binding's prefix.
Releases and rollback
A service with a source produces a release on every deploy: the repository is checked out at the branch's current commit, built with buildCommand, and published atomically under the site owner's home. The Releases panel on the Services tab lists them. A redeploy against an unchanged commit and unchanged compose is a no-op — the platform hashes both.
Native releases (sites and Node apps, buildKind: native) are built on the server in a scratch directory, never in the live tree, inside the build sandbox. The result is copied into releases/<id>/, sealed read-only (owned by root, mode 0550), health-checked, and made live by re-pointing the current link in one step: a failure before that step leaves the old release serving. Each release has a writable shared/ link for data that must survive releases. The newest five releases are kept, plus the live one.
Railpack releases (buildKind: railpack, container services only) build a container image from the source with Railpack instead of a directory. The release is the image tag, run by Compose like any other container; there is no site owner's Linux user and nothing is sealed.
Rollback re-publishes an earlier release without a rebuild: for a native release it re-points current, for a Railpack release it puts that image tag back into the compose. It rides an ordinary deploy, so compose, hostnames and certificates are applied as usual. A release is offered for rollback only if every server the environment deploys to has it. Rollback trusts only a release record that the daemon wrote on that server when the release was published, never the files in the site owner's home. Two consequences:
- A release published before the daemon began writing records (a daemon that predates release records) has no record on its server. A rollback to it fails with this host has no release record for it — redeploy that release; redeploy it once and it becomes a rollback target.
- A release that retention has removed from the server cannot be rolled back to, record or not (not present on this host).
Reference
Limits
| What | Limit |
|---|---|
| Hosting entries per service | 10 ports per hosting entry; hostnames unlimited but unique per organization |
source.branch | Same maximum length as the repository's branch field |
source.deployOnPush | true or false |
source.buildCommand, startCommand | Non-secret, single line |
cron[].name | Lowercase [a-z0-9-], unique per service; a name that a scheduled task also folds to is refused on the task, not here |
| PHP series | 8.3, 8.4 |
nodeVersion | A pin: 24, 24.17, 24.17.0 |
Deploy-time refusals that originate in the document
These are reported by Deploy, not by Save, because they depend on the merged document, the organization or the servers. The full deploy table is in Deploying.
| Code | Status | Meaning |
|---|---|---|
compose_merged_invalid | 422 | The project and overlay each saved cleanly but their merge is not a valid Compose document. |
compose_field_unsupported | 422 | An unsupported key is present; the reason is quoted. Drop the key. |
compose_field_requires_org_opt_in | 403 | A host-level feature is present and the organization has not turned them on. Names the fields and paths. |
compose_host_access_requires_manager | 403 | Host-level features are on, but the person deploying is not an organization manager or owner. |
compose_host_access_requires_approval | 403 | A Git-triggered deploy of host-level content that no manager or owner has deployed in this form. Deploy it once from the app to approve it. |
principal_alias_unknown | 422 | A service names an alias the root does not declare (only reachable with a stale document). |
principal_required_for_service_kind | 422 | A site or Node app has neither an alias nor a system user assigned on Bindings. |
site_principal_ambiguous, source_principal_ambiguous | 422 | No alias, and more than one system user could own the service. Declare the alias. |
site_cron_unowned, site_managed_directory_unowned | 422 | A site's cron job or managed directory has no owning account. |
source_ref_unresolved | 422 | The branch does not exist in the repository, or the provider refused the lookup. |
docker_external_network_unregistered | 422 | networks.<name>.external: true names a network not registered under Network → Docker networks. |
turbofabric_required | 422 | Services scheduled to different servers share a non-overlay network. |
Errors at save
| Code | Status | Meaning |
|---|---|---|
compose_invalid | 400 | The document failed validation; issues[] lists each path and message. |
compose_service_name_read_only | 400 | composeServiceName was sent on a service update; the name is derived from the document and cannot be set on the row. |
invalid_service_options | 400 | A per-service option (restart policy, health-check policy, build cache, resources) has an invalid value. |
service_create_not_supported | 400 | Services on a managed project are created by the platform, not by hand. |
docker_run_unsupported | 422 | The importer met a flag it cannot express; the flag is named. |
Related
- Projects and environments — the container the document lives in.
- Deploying and running an environment — placement, preview, deploy, and every refusal.
- Compose contract — the same rules from the platform's side.
- Datacenter networks — how an overlay network spans servers.
Last updated on
Projects and environments
What a project and an environment each own, the six ways to create a project, the project compose versus the environment overlay, server placement, and every field and error code on the way
Deploying and running an environment
Placement, preview, deploy and redeploy, start, stop and destroy, health-check gates, container logs, deployment history, and the meaning of every refusal code