Skip to main content
Version: 1.4

Authoring a template

A template is written as a Docker Compose document — the same YAML your team already writes — plus a few label directives for the things plain Compose has no concept of.

Global templates are authored by an admin or a template_admin. A project's own templates are also its maintainers' to create, edit, delete and update from the catalog (since 1.1.2), from the project's Templates page; test runs stay with the global roles. Whatever the scope, a spec that reaches past the container onto the Docker host is subject to the unsafe configuration rule below.

If the service you are modelling is a Spring Boot, Quarkus or Rails application, the create form can start you from a skeleton rather than an empty editor.

An agent holding an API key can run this whole loop — draft, validate, save, test-run, read the logs — over MCP, against the same parser and the same rules below.

The supported Compose subset​

Under services:, these keys are understood:

image, command, entrypoint, environment, ports, volumes, depends_on (with condition:), healthcheck, restart, networks (with aliases), privileged, user, labels, and resource limits as either mem_limit/cpus or deploy.resources.limits. Top-level networks: and volumes: are accepted too, but the deploy makes both itself: every container of an instance joins the one bridge network the deploy creates for it, and answers there to the name the environment gives it; every named volume a service mounts is created per instance, on the local driver. A bare declaration therefore loses nothing — and Compose wants one for each named volume. What asks for more is not applied: a volume's driver, driver_opts or labels, a network driver other than bridge, a service's network aliases. Validation warns about each of them, and never refuses them.

Format variants are handled transparently — string ports ("5432:5432") and long form, string and mapping volumes, healthcheck with its usual sub-keys, and command/entrypoint as either a shell string (split into words the way Compose does) or a list.

Service keys become names. A key under services: may use letters, digits, underscores and dashes, and must start with a letter or digit — up to 40 characters. That is Compose's own habit minus one character: a dot is not allowed, because the key ends up inside the ingress route's name, where a dot makes the rest of it read as something else and the service quietly stops being routed. Underscores and capitals are fine, so a Compose file you already have almost certainly passes as it stands. The rule applies when you save; a template stored before it keeps deploying until it is next edited.

The spec editor completes these keys as you type, so this table is a reference rather than something to keep in your head.

Two rules to know:

  • Any unsupported key is a hard error, not a silent drop. build, env_file, profiles and secrets will be rejected by name when you save. This is deliberate: a silently ignored key is a deploy that quietly does the wrong thing.
  • ${VAR} interpolation is not performed. Templates are static documents. Use environment overrides, instance placeholders or git value references instead.

Label directives​

Four labels are consumed at parse time and never reach the container:

LabelPurpose
fadebox.roleinit, sidecar, or main/absent. See templates.
fadebox.ingress.portComma-separated container ports to expose through the ingress proxy.
fadebox.ingress.pathRoute those ports by path prefix on the shared instance host — one path per port, same order.
fadebox.ingress.striptrue to strip the prefix before proxying.

An ingress port needs no ports: entry — it is reached through the proxy over the Docker network and is deliberately not published on the host. A port that also has an explicit host mapping keeps it. See Ingress and instance URLs. A port that is published on the host, with a fixed host port or without one, cannot share a static runtime with a gated instance: leave the ports: entry out unless something outside the instance has to reach it.

Unsafe configuration​

Four things a spec can say reach past the container onto the Docker daemon itself — the machine every project deploys to:

ConfigurationWhy it is unsafe
privileged: trueThe container holds the host kernel's capabilities: root on the daemon host.
A volume whose source is a path (/var/run/docker.sock:…, ./data:…)A bind mount reads and writes the host's own filesystem, not storage Fadebox owns. Named volumes are fine.
A port with a host binding (8080:80)It answers on the host outside the ingress, where nothing scopes it to the instance and a second instance collides with it.
A label under traefik. or fadebox.Labels reach the container verbatim and both the ingress and Fadebox read them back — a route or an owner can be claimed for another project's instance. The four directives are the way to ask for ingress.

Fadebox reports these as unsafe findings, one sentence per service, on every validation — the spec editor's readback, POST /api/template-validation and the agent's validate_template list them under unsafe, apart from the advisory warnings.

For a project template they are more than a warning: saving, importing or updating one that carries any of them is refused unless the project's administrator has allowed it. The switch is Project → Settings → Template Safety, visible to every member and movable only by a global admin (a switch a maintainer could flip would gate nothing). A global template is never refused — only the global template roles write one — and its findings stay advisory.

The rule runs when a spec is written, never at deploy. Turning a project's switch off leaves its saved templates and their running instances exactly as they are; the next edit of an unsafe template in that project is what asks for the switch. Upgrading to 1.1.2 therefore changes nothing that already runs.

Healthchecks earn their keep​

A service with a healthcheck is waited for until it is healthy; one without is only waited for until it has started. Since waves gate on exactly that, a database with no healthcheck will let the next wave start before it can accept connections.

::::note "Wait until it is up" is only as strong as your healthchecks The same rule decides when the whole deploy settles into RUNNING — the status the CLI's --wait and a CI pipeline's polling loop gate on. With healthchecks, RUNNING means every service answered its probe; without them, it merely means the containers started, and a pipeline can begin hitting an app that is not yet accepting connections. A healthcheck is not polish on a template — it is the template saying what up means, and it is what makes waiting for RUNNING worth anything. ::::

services:
postgres:
image: postgres:18-alpine
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: postgres
volumes:
- postgres-data:/var/lib/postgresql/18/docker
healthcheck:
test: ["CMD-SHELL", "sh -c 'pg_isready -U $POSTGRES_USER -d $POSTGRES_DB'"]
start_period: 20s
interval: 5s
timeout: 5s
retries: 12

Injected files​

A template can carry files that are written into a container at deploy time — an init script, a config file, a seed SQL dump. Each file names the service inside the unit it targets (so an init container can carry its own script), an absolute path inside that container, and its content. Files are text, up to 1 MB, and land with mode 0644.

A target path underneath one of that service's volume mounts is rejected when you save it, because the volume would shadow the file the moment the container starts.

An environment service can supply a file at the same path to override a template file — environment files are keyed by container too, so they reach a unit's helpers just like the template's own.

Values only the instance knows​

Some values cannot be written into a template because they differ per instance: a frontend's API URL, an OIDC issuer, a CORS allow-list. Rather than generating those per run, write a placeholder and let the deploy fill it in:

PlaceholderResolves to
{{instance.name}}The instance slug, e.g. pr-42.
{{instance.namespace}}{project}-{environment}-{instance} — the container-name prefix.
{{instance.host}}The instance's own hostname, {namespace}.{ingress-domain} — with :port appended on a runtime whose ingress port is not the scheme default, so {{ingress.scheme}}://{{instance.host}}/… stays a working URL everywhere.
{{ingress.domain}}The runtime's ingress domain.
{{ingress.scheme}}http or https, per the runtime.
{{ingress.port}}The port instance URLs are reached on — 80/443 or the runtime's override. For templates that build a per-service host from {{ingress.domain}} and need :{{ingress.port}} to survive a non-default-port runtime.

These interpolate inside a string, so https://{{instance.host}}/auth works, and they apply both to environment values and to injected file content.

Spelling matters: anything that looks like one of these placeholders but is not exactly right — {{ instance.host }} with spaces, say — fails the deploy instead of reaching the container as literal text. A typo in a callback URL should not become a running container.

These are one of three placeholder families. Placeholders covers all three together: the order they resolve in, which surfaces accept which, and what each failure means.

Values another service owns​

A template that connects to a sibling — Keycloak to its database, an app to its cache — can reference that service instead of copying its values:

PlaceholderResolves to
{{service.<name>.host}}The in-instance hostname of environment service <name>.
{{service.<name>.env.<KEY>}}The effective value of env var KEY on that service's main container.

<name> is the service's name in the environment. The same rules as instance placeholders apply — interpolation inside strings, env values and injected files, malformed references fail the deploy — plus two of their own: referencing a service that is not in the environment fails the deploy with a message naming both sides (instead of a crash-loop at runtime), and references are one level only — a referenced value may not itself be a reference.

The payoff is a single source of truth: override the provider's password once, at the environment level, and every consumer follows on the next deploy:

KC_DB_URL: jdbc:postgresql://{{service.postgres.host}}:5432/{{service.postgres.env.POSTGRES_DB}}
KC_DB_PASSWORD: "{{service.postgres.env.POSTGRES_PASSWORD}}"

Renamed the provider? Override the consumer's variable in the environment with a reference to the new name — the template stays untouched.

Nobody has to write these from memory. In the spec editor, typing {{ offers the placeholders above; {{service. offers the templates this one could be composed alongside — the project's own plus the global ones — and {{service.<name>.env. offers that template's variables. Accepting a suggestion closes the braces for you.

What the editor completes are template names, because a template is authored before any environment exists. That is a good guess rather than a promise: the composer names a service after the template it comes from by default, but the name is the environment's to change, and {{service.…}} resolves against whatever it ends up being. Rename it and you override the consumer's variable, as above.

The env editors that sit inside an environment (the composer's per-service overrides, and the add/edit-service drawers) go further, because there the environment is real: {{service. completes from the environment's own services, and a Service reference builder next to the git one lets you pick a sibling service, then its hostname or one of its env vars, and drops the finished placeholder at the caret. The composer also warns when a service's template or overrides reference a name the environment doesn't have yet; the warning is non-blocking, because the deploy is the enforcement point and the missing service may simply not be added yet.

Test runs​

Before wiring a template into an environment, run it on its own: Test run starts the unit ad hoc on a runtime, with no project or environment involved, and shows container status and logs.

A test run is not persisted. Its containers are labelled and the daemon is the source of truth, so a run survives an app restart and stays stoppable. There is one test run per template, in the namespace tpl-test-<template>.

A template that carries {{service.*}} references cannot test-run — the references need sibling services, and a test run deploys the template alone. Compose it into an environment instead.

The spec editor​

The spec field is a code editor: YAML highlighting, line numbers, folding, and search (Ctrl/Cmd+F). Tab indents inside it rather than moving to the next field, so use Escape then Tab to leave. It completes placeholders as you type — see values another service owns.

It also completes the Compose spec itself, which is why the subset table above is worth reading once rather than memorising. Start typing a key and the editor offers the ones valid at that indentation — the top level, a service, a healthcheck, a long-form ports or volumes entry — each with a one-line hint. Keys whose value is an enum offer it after the colon: restart, a depends_on condition, a port protocol. Under labels:, the four directives are offered by name, with their values.

What it offers is exactly what the parser accepts, so a suggested key is never one that gets rejected on save. The reverse is not a promise: a key the editor doesn't suggest at that spot may still be valid YAML the parser takes — completion is a shortcut, and the save is the authority.

The same editor holds injected file contents and highlights them by what the target path says the file is: YAML, JSON, SQL, XML, TOML, shell (.sh, .bash, or a script whose first line is a #! naming a shell), env- and ini-style config (.env, .properties, .ini, .conf, .cfg, .cnf) and nginx config (a path under nginx/ or named nginx.conf). Anything else is plain text. Placeholders are marked in all of them.

Starting from a framework skeleton​

The create-template form has a Start from picker above the spec field. Choosing Spring Boot, Quarkus or Ruby on Rails fills the editor with a commented skeleton for that framework, and Blank empties it again. Only the spec is filled — the name, slug, description and scope stay yours, because the template models your application, not the framework it happens to use. If the spec already holds something you typed, switching asks before replacing it.

A skeleton is a one-off copy, not a subscription: what you save is an ordinary template with no link back, and nothing updates it later. Replace the image, delete what you don't need.

Each one wires up the four things a first application template usually gets wrong.

An ingress port, and the framework's forwarded-headers switch. fadebox.ingress.port is what publishes the app at its instance URL. Without the matching framework setting — SERVER_FORWARD_HEADERS_STRATEGY for Spring Boot, QUARKUS_HTTP_PROXY_PROXY_ADDRESS_FORWARDING for Quarkus, nothing for Rails — the links and redirects the app generates point at localhost instead of the instance. Traefik overwrites those headers for traffic arriving from outside, but a container belonging to another instance shares the edge network and can set them directly, so don't make authorization decisions on them.

The database by reference rather than by literal, using {{service.postgres.*}} so that renaming the provider or overriding its password rewires the app instead of breaking it. Delete those lines if the app has no database, or change postgres to whatever you named that service.

A healthcheck the image can actually run — the part that most often goes wrong, below.

Migrations as an init helper, in the Rails skeleton: db:prepare runs as a fadebox.role: init helper on the app's own image, so an image-tag override moves the app and its migrations together, and the wave waits for the migration to exit 0 before the app starts.

The healthcheck has to match your image​

Waves gate on healthy, so a probe that can never succeed does not merely leave a container looking unwell — it stalls the deploy until it times out, with nothing in the logs to say why. The probe has to be a tool the image actually ships, and the JVM images differ more than you would expect:

ImagecurlwgetCan run java Health.java
eclipse-temurin:*-jreyesyesno
ubi9/openjdk-*-runtimeyesnoyes
Images built by buildpacksoften neitheroften neitherno

The skeletons probe with curl -fsS, which covers the first two. If your image ships neither curl nor wget — buildpack-built images frequently ship neither — add one in your Dockerfile, or pick a run image that has one.

The last column is the trap. java Health.java looks like a probe with no dependencies beyond the JVM, but running a .java file directly compiles it first, and a JRE has no compiler. On a JRE image — the usual base for a Spring Boot service — that probe fails forever.

Secrets are not shipped in a skeleton​

The Rails skeleton deliberately leaves SECRET_KEY_BASE unset, and Rails will not boot in production without one. That is the intent: a value shipped inside a skeleton would be identical in every copy of it, and it signs session cookies on a URL the ingress publishes, so anyone who knew the skeleton could forge a session. Set your own — on the db-prepare helper as well as the app, since an environment override reaches only the main container — or read it from git.

A worked example​

services:
api:
image: registry.example.com/shop/api:1.4.0
environment:
DATABASE_URL: jdbc:postgresql://postgres:5432/shop
PUBLIC_BASE_URL: "{{ingress.scheme}}://{{instance.host}}"
labels:
fadebox.ingress.port: "8080"
fadebox.ingress.path: "/api"
fadebox.ingress.strip: "true"
depends_on:
migrate:
condition: service_completed_successfully
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8080/q/health/ready"]
start_period: 15s
interval: 5s
retries: 20
deploy:
resources:
limits:
memory: 1g

migrate:
image: registry.example.com/shop/api:1.4.0
command: ["./migrate.sh"]
environment:
DATABASE_URL: jdbc:postgresql://postgres:5432/shop
labels:
fadebox.role: init

postgres is not in this file: it is a separate template, composed alongside this one in the environment, and reached here by its environment service name.