Placeholders
A placeholder is a {{…}} marker you write into a configuration value instead of a literal.
Fadebox replaces it when the instance deploys, every time it deploys.
They exist because three kinds of value cannot honestly be written into a template: what only the running copy knows (its own hostname), what lives somewhere else and changes without you (the release tag in a values file), and what belongs to a service next door (the database password). Without placeholders each of those has to be generated per instance and pushed in as configuration — which is exactly the state that rots, silently, until a deploy uses a value that stopped being true weeks ago.
The three families
| Family | Example | Resolves to | Detail |
|---|---|---|---|
| Instance and ingress | {{instance.host}} | What this copy is called and where it answers | Values only the instance knows |
| Git | {{git:app-repo:deploy/values.yaml#$.image.tag}} | A scalar read out of a file in one of the project's repositories | Git value sources |
| Service | {{service.postgres.env.POSTGRES_PASSWORD}} | The effective value on a sibling service of the same instance | Values another service owns |
Those pages are the reference for each family, down to the last key. This page is the part that sits above them: when they resolve, where you may write them, and what to do when one fails.
When they resolve
All three resolve on the deploy, on the worker, before any container exists. Nothing is substituted when you save a template or compose an environment, so a value is never frozen at authoring time: change the provider's password, or the tag in the values file, and the next deploy picks it up.
They resolve in a fixed order, and the order is the useful part:
- Instance and ingress — plain interpolation against values the deploy already holds.
- Git — the repositories are fetched and the JSONPaths evaluated.
- Service — last, so a referenced value is already final.
Because service references go last, a provider whose password comes from git hands every consumer
the resolved scalar, not the reference. You get one source of truth without every consumer
needing access to it. And because instance placeholders go first, a git-sourced value that happens
to contain {{instance.…}} is treated as data, not expanded a second time.
References are one level deep. A referenced value that is itself a service reference fails the deploy rather than being chased, which keeps resolution a single pass with no cycles to reason about.
Where you can write them
Not every family works on every surface, and the differences are not arbitrary — each one follows from what the resolver has in its hands at that moment.
| Surface | Instance / ingress | Git | Service |
|---|---|---|---|
Template environment: values | ✓ | ✓ | ✓ |
| Template injected files | ✓ | — | ✓ |
| Environment shared variables | ✓ | ✓ | ✓ |
| Environment service variable overrides | ✓ | ✓ | ✓ |
| Environment service injected files | ✓ | — | ✓ |
| Environment service image tag | — | ✓ | — |
| Instance overrides (a deployer's own values) | — | — | — |
Three rows are worth saying in words.
Injected file content cannot come from git. A git reference resolves to one scalar out of one file; it is not a file loader. Instance and service placeholders do work inside file content, so a seed script or a config file can carry the instance's hostname or the database's password.
An image tag takes a git reference and nothing else. That is the whole point of the feature —
"deploy whatever version the values file says" — and anything else in tag position is rejected when
you save the service row, not at deploy time. A tag must either match Docker's tag grammar or be
one whole {{git:…}} reference.
A deployer's overrides are always literal. Any value containing {{ is refused on the spot.
This is a fence, not a limitation: an override is set by whoever creates an instance, and a
reference there would let them name a path in the project's repositories and have the server fetch
it with the project's credentials. "Latest test build" stays where an editor put it, on the
environment's service row.
The two grammars
Instance, ingress and service placeholders interpolate inside a string:
PUBLIC_BASE_URL: "{{ingress.scheme}}://{{instance.host}}"
KC_DB_URL: jdbc:postgresql://{{service.postgres.host}}:5432/{{service.postgres.env.POSTGRES_DB}}
A git reference is the whole value or nothing:
# Right — the value is one reference.
IMAGE_TAG: "{{git:app-repo:deploy/values.yaml#$.image.tag}}"
# Wrong — a reference cannot be part of a larger string.
DATABASE_URL: "postgres://db:5432/{{git:app-repo:deploy/values.yaml#$.db.name}}"
It resolves to a scalar read out of a file, so there is nothing sensible to interpolate it into. When you need a git-sourced value inside a longer string, put the reference on its own variable and let the application compose the two.
How placeholders meet the environment-variable layers
Environment variables arrive in three layers, and an instance's own overrides make a fourth. Placeholders are resolved after the layers are merged, so what gets resolved is whichever value won:
- Environment shared variables — underneath everything, and the only layer that reaches helper containers as well as the main one.
- The template's own
environment:. - The environment service's overrides — main container only, and the last word of the definition.
- The instance's overrides — the whole unit, and always literal.
The practical consequence: overriding a variable at a higher layer replaces the placeholder
rather than feeding it. If a template sets KC_DB_PASSWORD to a service reference and someone
overrides that variable on the environment service row with a literal, the reference is gone for
that service. Override the provider's value instead, and every consumer that references it
follows on the next deploy.
Scenarios
An application that has to know its own URL
A frontend calling its own API, an OIDC client registering a redirect URI, a service writing absolute links into email: all of them need a URL that differs per instance and does not exist until the deploy.
services:
api:
image: registry.example.com/shop/api:1.4.0
environment:
PUBLIC_BASE_URL: "{{ingress.scheme}}://{{instance.host}}"
CORS_ALLOWED_ORIGINS: "{{ingress.scheme}}://{{instance.host}}"
labels:
fadebox.ingress.port: "8080"
fadebox.ingress.path: "/api"
fadebox.ingress.strip: "true"
{{instance.host}} already carries :port on a runtime whose
ingress port is not the scheme default, so the URL
above stays correct on a laptop running the ingress on :8880 and on a production runtime on
:443. Build the URL from {{ingress.scheme}} and {{instance.host}} and you never have to think
about it again.
A consumer that must not hardcode its provider's credentials
The catalog's Keycloak entry connects to PostgreSQL this way, and it is the pattern for any pair:
KC_DB_URL: jdbc:postgresql://{{service.postgres.host}}:5432/{{service.postgres.env.POSTGRES_DB}}
KC_DB_USERNAME: "{{service.postgres.env.POSTGRES_USER}}"
KC_DB_PASSWORD: "{{service.postgres.env.POSTGRES_PASSWORD}}"
Only the provider holds the values. Override the password once, on the postgres service, and
Keycloak follows on the next deploy. Add a second database under a different name and the consumer
that references postgres still connects to the right one. Name the provider something else —
auth-db — and the deploy fails with a message naming both sides, instead of Keycloak
crash-looping with a JDBC stack trace ten seconds later.
A version that lives in the GitOps values file
# On the environment service row, in the image tag field:
{{git:app-repo:deploy/values.yaml#$.image.tag}}
The file your deployment tooling already reads stays the single source of truth. A release bumps
image.tag there, and the next deploy of any environment referencing it picks the new version up.
Nobody writes a version into Fadebox, and no pipeline needs a key that can edit environments. Each
deploy records which commit it read,
so "what actually ran" is answerable afterwards.
A seed script that knows which instance it is in
Injected files take instance and service placeholders in their content, which is what lets one script serve every copy:
-- /docker-entrypoint-initdb.d/10-seed.sql, injected on the postgres service
INSERT INTO tenants (slug, base_url)
VALUES ('{{instance.name}}', '{{ingress.scheme}}://{{instance.host}}');
Every instance seeds itself with its own name and URL. Nothing is generated per run, and there is no per-instance configuration to keep in step.
One database password for a stack, read from git
Combine two families and the order does the work:
# postgres service row, environment override:
POSTGRES_PASSWORD: "{{git:infra-repo:secrets/dev.yaml#$.postgres.password}}"
# every consumer's template:
DB_PASSWORD: "{{service.postgres.env.POSTGRES_PASSWORD}}"
Git resolves before service references, so consumers receive the fetched scalar. Rotate the value in the repository and the whole stack moves on its next deploy. Note the boundary: git-sourced values are configuration, not a secrets manager.
When a placeholder fails
The rule across all three families: anything placeholder-shaped that is not exactly right fails the deploy. It never reaches the container as literal text. A typo in a callback URL should not become a running container that half works.
| What you see | What it means | The fix |
|---|---|---|
Malformed placeholder {{ instance.host }} … expected {{instance.host}} with no spaces | Placeholder-shaped, but not the exact grammar | Remove the spaces; the braces close tight around the key |
Unknown placeholder {{instance.url}} … known placeholders are … | A key that does not exist | Use one from the table; the message lists them all |
{{instance.host}} … needs an ingress domain, and runtime has none | The runtime has no ingress domain, so there is no hostname to substitute | Set an ingress domain on the runtime, or drop the placeholder — an empty string would produce https:///auth |
| A message naming a service that is not in the environment | A {{service.…}} reference whose target is missing or renamed | Add the service, or point the reference at the name the environment actually uses |
Malformed git reference … expected {{git:<repo>[@<ref>]:<file-path>#<json-path>}} | The reference does not parse | Use the reference builder rather than typing it |
| A git failure naming the full reference | Unreachable remote, missing ref, missing file, or a path matching nothing or more than one value | The message is the instance's error verbatim — it names which reference broke |
A failure at deploy time lands the instance in ERROR with that message as its error, so the
status panel tells you which placeholder to go and look at.
Two of these are caught earlier than deploy time, on purpose: a malformed git reference in an image tag is refused when you save the service row, and the composer warns while you are composing when a service's template references a name the environment does not have yet. That warning is deliberately non-blocking — the service you are referencing may simply not be added yet, and the deploy is the enforcement point.
Where placeholders are not substituted
A template test run. Test runs deploy one template alone,
with no environment, no instance and no ingress. A template carrying {{service.*}} references is
refused up front, because a test that passed against invented values would be worse than no test.
Instance and ingress placeholders are not resolved there either — there is no instance — so a
template that depends on them is one to verify by composing it into an environment rather than by
test-running it.
A Compose export. Exporting resolves service references, because the environment's definition holds everything they need. Git references and instance placeholders have no resolver outside Fadebox, so they come out verbatim under a header comment telling you to replace them with literals before running the file. Nothing is silently substituted with a value the environment did not say.
A deployer's overrides. Refused when set, as above.
Writing them without typing them
Nobody should write these from memory, and mostly nobody has to.
- The spec editor completes them: typing
{{offers the instance and ingress keys,{{service.offers the templates this one could sit beside, and{{service.<name>.env.offers that template's variables. Accepting a suggestion closes the braces for you. - The environment env editors — the composer's per-service overrides and the add- and edit-service drawers — go further, because there the environment is real: a Service reference builder next to the git one, where you pick a sibling and then its hostname or one of its variables, and the finished placeholder lands at the caret.
- The git reference builder browses the repository at the chosen ref, lists the parsed keys of the file you pick, and writes the reference. A resolve preview shows what it evaluates to right now, without deploying.
One caveat on the spec editor: what it completes are template names, because a template is
authored before any environment exists. The composer names a service after its template by default,
but the name is the environment's to change, and {{service.…}} resolves against whatever it ends
up being.