In a compose.yaml, how do `${VAR:-default}`, `${VAR-default}` and `${VAR:?err}` differ, and when would you use each one?
answer
- unset versus set-but-empty
- what the colon adds
- fail while loading, not at runtime
- blank string plus a warning
basics
~20 s${VAR:-default} uses the default when VAR is unset or empty, ${VAR-default} only when it is unset, and ${VAR:?err} makes Compose stop with err when VAR is unset or empty. Use defaults for tunables and :? for values that must be supplied.
solid answer
~40 sAll three are Compose interpolation forms, resolved while the file is parsed. The colon means "unset **or empty**": `${API_PORT:-8080}` gives 8080 even when a CI job exports `API_PORT=` as an empty string, while `${API_PORT-8080}` keeps the empty value and only falls back when the variable does not exist. `${DB_PASSWORD:?set DB_PASSWORD}` turns a missing secret into a load-time failure — Compose reports `required variable DB_PASSWORD is missing a value: set DB_PASSWORD` and creates nothing. A plain `${DB_PASSWORD}` that is unset only produces a warning and a blank string, so a database can start with an empty password. I use `:-` for ports, tags and log levels, `:?` for credentials, and the no-colon forms when an empty value is a deliberate setting. A literal dollar sign is written `$$`.
code
yaml · 9 linesservices:
api:
image: "registry.example/api:${APP_TAG:-dev}"
ports:
- "${API_PORT:-8080}:8080"
environment:
DB_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD must be set}
NODE_OPTIONS: ${DEBUG:+--inspect}
command: sh -c "echo booting on $$HOSTNAME && exec node server.js"go deeper
Recall that ${VAR:-default} supplies a fallback and ${VAR:?message} makes a variable mandatory, and that both are resolved when Compose reads the file.
Explain the colon precisely: with it, empty counts as missing; without it, only an unset name does. Show which form you would pick for a port, a tag and a password.
Show how the forms decide CI behaviour: pipelines export empty variables, a missing secret should fail the load with :? rather than start a database with a blank password, and $$ protects runtime shell variables.
Weigh a convention for a shared stack: which values may default silently, which must fail loudly, and how the rule is enforced by a docker compose config step in every pipeline.
## Interpolation in one paragraph **Interpolation** is the step in which Docker Compose replaces variable placeholders in `compose.yaml` with text before it builds the project model. Values come from the **interpolation environment**: the variables exported in the shell that runs `docker compose`, plus an env file (the project `.env`, or files passed with `--env-file`). Placeholders can be braced (`${VAR}`) or bare (`$VAR`), and they are allowed in any YAML *value* — image tags, ports, paths, `environment` values — but not in YAML *keys*. ## The braced forms Compose's parser (compose-go's `template` package) supports six modifiers on top of plain `${VAR}`: | Form | `VAR` unset | `VAR` set but empty | `VAR=x` | |---|---|---|---| | `${VAR}` | empty string, with a warning | empty string | `x` | | `${VAR:-d}` | `d` | `d` | `x` | | `${VAR-d}` | `d` | empty string | `x` | | `${VAR:?e}` | error `e`, load fails | error `e`, load fails | `x` | | `${VAR?e}` | error `e`, load fails | empty string | `x` | | `${VAR:+r}` | empty string | empty string | `r` | | `${VAR+r}` | empty string | `r` | `r` | Other shell parameter expansions, such as `${VAR/foo/bar}`, are **not** supported. ## What the colon changes The whole family turns on one distinction: - **Unset** means the name does not exist in the interpolation environment at all. - **Set but empty** means it exists with an empty value — a line `API_PORT=` in an env file, or `export API_PORT=` in a CI job. - A form **with** a colon treats empty the same as unset; a form **without** a colon treats empty as a real value. That matters most when a stack moves from a laptop to a CI job. CI systems often define every pipeline variable and leave unused ones empty, so `${API_PORT-8080}` yields an empty host port on the runner while it worked on the laptop, where the variable did not exist. ## Choosing a form 1. **Tunables with a safe default** — ports, image tags, log levels: `${API_PORT:-8080}`, `${APP_TAG:-dev}`. 2. **Values that must be supplied** — passwords, tokens, the target registry: `${DB_PASSWORD:?DB_PASSWORD must be set}`. Any command that loads the project, such as `docker compose config` or `up`, fails before a container is created. 3. **Deliberately empty settings** — a variable whose empty value means "off": use `${VAR-default}` so an explicit empty value survives. 4. **Conditional snippets** — `${DEBUG:+--inspect}` adds a flag only when `DEBUG` has a value. Without a `?` form, an unset variable does *not* stop Compose: a plain `${DB_PASSWORD}` logs `The "DB_PASSWORD" variable is not set. Defaulting to a blank string.` and carries on. That is why a required secret deserves `:?`. ## Where interpolation applies - **Values, not keys.** A placeholder in a YAML key, such as a label name written as a map key, is left alone. For the few attributes whose keys are free text, `labels` and `environment`, the list syntax `- "${NAME}=value"` puts the variable in a value, where it is interpolated. - **Every file, before merging.** When several compose files are combined with `-f`, each is interpolated on its own and the results are then merged, so a placeholder is resolved in the file where it is written. - **Env files too.** Unquoted and double-quoted values in the project `.env` and in `env_file` files are interpolated, which is how `COMPOSE_DEBUG=${DEV_MODE:-false}` in `.env` can depend on the shell. Single-quoted values are literal. - **Only at parse time.** Nothing is re-evaluated when the container later starts; the container sees finished text. ## Escaping and nesting - **`$$`** produces a literal `$`, so `command: sh -c "echo $$HOSTNAME"` hands `echo $HOSTNAME` to the container's shell, which expands it at run time. - A `$` that does not start a valid name or `${` is left alone. - Defaults can nest: `${API_PORT:-${DEFAULT_PORT:-8080}}`. - The error text of `:?` is itself interpolated, so `${DB_PASSWORD:?missing in $CI_ENV}` works. ## Seeing the result `docker compose config` renders the file with every placeholder resolved, and fails with the `:?` message if a required variable is missing, which makes it a cheap first step in a CI job.
- When exactly does a ${VAR:?err} failure surface?While Compose loads and interpolates the project, before it talks to the Docker engine. Commands such as `docker compose config` or `docker compose up` exit with `required variable VAR is missing a value: err`, and no container, network or volume is created, which is exactly the point.
- How do you write a dollar sign in compose.yaml that Compose must not interpolate?Double it: `$$`. Compose turns `$$VAR` into the literal text `$VAR`, so a shell inside the container, for example `sh -c`, can expand it at run time with the container's own value.
- Can the default in ${VAR:-default} itself come from another variable?Yes, interpolation nests: `${API_PORT:-${DEFAULT_PORT:-8080}}` tries `API_PORT`, then `DEFAULT_PORT`, then the literal 8080. The error message of the `?` forms is interpolated too.
saying these in an interview costs you the question
- ${VAR:-default} and ${VAR-default} behave the same in every case.
- An unset variable in compose.yaml makes docker compose up fail by default.
- ${VAR:?err} is only checked when the container reads the variable.
- A bare $VAR without braces is always passed through to the container literally.
- Compose accepts every bash expansion, such as ${VAR/foo/bar}, inside compose.yaml.