FYI for docs. Three cases where a docker-compose.yml that built fine under v24 either fails or silently behaves differently at runtime under v25. All three trace back to the same change: v24 deferred variable resolution to the runtime shell inside /bin/sh -c …, v25 resolves at parse time and freezes the result into the image config.
Want to confirm each one before we write it up so the docs are right and we know which are intentional vs known rough edges.
All outputs below come from calling @balena/compose-parser's parse() directly with referenced env vars unset. See repro.
1. Bare $VAR for runtime-shell expansion
services:
test:
entrypoint: ["/bin/sh", "-c", "echo MOD_PATH_IS=[$MOD_PATH]"]
v24: parser passes $MOD_PATH through verbatim. The runtime /bin/sh -c sees the literal $MOD_PATH and expands it from the container env at exec time. Works if MOD_PATH is set on the device via environment:, env_file:, fleet/device service vars, etc.
v25: parser interpolates against process.env at parse time. With MOD_PATH unset on the host, the entrypoint becomes:
["/bin/sh", "-c", "echo MOD_PATH_IS=[]"]
The empty string is baked into the image config before the device sees it, so container-side env vars no longer have a chance to fill it in.
Workaround: escape the dollar so it survives parse-time interpolation and reaches the runtime shell. Use $$MOD_PATH.
Question: is there a parser option callers can pass to opt out of interpolation for a specific field? Would document that as the escape hatch if it exists.
2. Service-level environment: no longer feeds entrypoint vars
services:
test:
environment:
MY_VAR: from-service-environment-block
entrypoint: ["/bin/sh", "-c", "echo seen=[$MY_VAR]"]
v24: parser passes $MY_VAR through verbatim. The supervisor launches the container with MY_VAR=from-service-environment-block in its env, the runtime sh -c substitutes, output is seen=[from-service-environment-block].
v25: parser interpolates $MY_VAR at parse time, only against host env. Service-level environment: is runtime-only and not visible to the interpolation pass, so the entrypoint becomes:
["/bin/sh", "-c", "echo seen=[]"]
The container still launches with MY_VAR in its env, but the entrypoint string has already lost the reference.
Same underlying change as case 1, but worth flagging separately. People who set a variable in environment: because their entrypoint references it had a working config in v24 that silently degrades in v25.
Workaround: same as case 1, use $$MY_VAR so the dollar reaches the shell. Or move the assignment into the entrypoint payload itself: ["/bin/sh", "-c", "MY_VAR=from-service-environment-block; echo seen=[$MY_VAR]"].
3. ${VAR:?msg} hard-fails at parse instead of at runtime
services:
test:
command: ["echo", "${MY_REQUIRED_VAR:?must-be-set}"]
v24: parser passes the whole ${VAR:?msg} token through verbatim. The :? enforcement happens inside the runtime shell. If the shell ever sees an unset variable at exec time, it errors with msg. Compose files that used ${VAR:?msg} for documentation purposes ("you should set this") rather than strict enforcement still built and ran fine as long as the variable was eventually present in container env.
v25: parser enforces :? at parse time, against host env. With MY_REQUIRED_VAR unset on the host, parsing fails:
ComposeError: Failed to parse compose file:
error while interpolating services.test.command.[]:
required variable MY_REQUIRED_VAR is missing a value: must-be-set
balena build and balena push exit non-zero before any image is built.
Workaround: either ensure the host env has the variable set during CLI invocation, or drop the :? and validate the variable's presence inside the container's entrypoint script.
Repro
// repro.mjs : node repro.mjs <fixture-dir>
import { parse } from '@balena/compose-parser';
import path from 'node:path';
for (const k of ['MOD_PATH','MY_VAR','MY_REQUIRED_VAR']) delete process.env[k];
const dir = path.resolve(process.argv[2] ?? '.');
process.chdir(dir);
try {
const c = await parse(path.join(dir, 'docker-compose.yml'));
const out = {};
for (const [name, svc] of Object.entries(c.services ?? {})) {
out[name] = { command: svc.command, entrypoint: svc.entrypoint };
}
console.log(JSON.stringify(out, null, 2));
} catch (e) {
console.log('ParseError:', e.message);
}
Tested against @balena/compose-parser bundled in balena-cli@25.1.6. v24 baseline established against @balena/compose bundled in balena-cli@24.1.4, which doesn't do interpolation in the parser layer at all. grep for interpolat|substitute in both node_modules/@balena/compose and build/utils/compose_ts.js returns nothing.
Research and test cases developed with Claude (Anthropic). All outputs reviewed manually.
FYI for docs. Three cases where a
docker-compose.ymlthat built fine under v24 either fails or silently behaves differently at runtime under v25. All three trace back to the same change: v24 deferred variable resolution to the runtime shell inside/bin/sh -c …, v25 resolves at parse time and freezes the result into the image config.Want to confirm each one before we write it up so the docs are right and we know which are intentional vs known rough edges.
All outputs below come from calling
@balena/compose-parser'sparse()directly with referenced env vars unset. See repro.1. Bare
$VARfor runtime-shell expansionv24: parser passes
$MOD_PATHthrough verbatim. The runtime/bin/sh -csees the literal$MOD_PATHand expands it from the container env at exec time. Works ifMOD_PATHis set on the device viaenvironment:,env_file:, fleet/device service vars, etc.v25: parser interpolates against
process.envat parse time. WithMOD_PATHunset on the host, the entrypoint becomes:The empty string is baked into the image config before the device sees it, so container-side env vars no longer have a chance to fill it in.
Workaround: escape the dollar so it survives parse-time interpolation and reaches the runtime shell. Use
$$MOD_PATH.Question: is there a parser option callers can pass to opt out of interpolation for a specific field? Would document that as the escape hatch if it exists.
2. Service-level
environment:no longer feeds entrypoint varsv24: parser passes
$MY_VARthrough verbatim. The supervisor launches the container withMY_VAR=from-service-environment-blockin its env, the runtimesh -csubstitutes, output isseen=[from-service-environment-block].v25: parser interpolates
$MY_VARat parse time, only against host env. Service-levelenvironment:is runtime-only and not visible to the interpolation pass, so the entrypoint becomes:The container still launches with
MY_VARin its env, but the entrypoint string has already lost the reference.Same underlying change as case 1, but worth flagging separately. People who set a variable in
environment:because their entrypoint references it had a working config in v24 that silently degrades in v25.Workaround: same as case 1, use
$$MY_VARso the dollar reaches the shell. Or move the assignment into the entrypoint payload itself:["/bin/sh", "-c", "MY_VAR=from-service-environment-block; echo seen=[$MY_VAR]"].3.
${VAR:?msg}hard-fails at parse instead of at runtimev24: parser passes the whole
${VAR:?msg}token through verbatim. The:?enforcement happens inside the runtime shell. If the shell ever sees an unset variable at exec time, it errors withmsg. Compose files that used${VAR:?msg}for documentation purposes ("you should set this") rather than strict enforcement still built and ran fine as long as the variable was eventually present in container env.v25: parser enforces
:?at parse time, against host env. WithMY_REQUIRED_VARunset on the host, parsing fails:balena buildandbalena pushexit non-zero before any image is built.Workaround: either ensure the host env has the variable set during CLI invocation, or drop the
:?and validate the variable's presence inside the container's entrypoint script.Repro
Tested against
@balena/compose-parserbundled inbalena-cli@25.1.6. v24 baseline established against@balena/composebundled inbalena-cli@24.1.4, which doesn't do interpolation in the parser layer at all.grepforinterpolat|substitutein bothnode_modules/@balena/composeandbuild/utils/compose_ts.jsreturns nothing.Research and test cases developed with Claude (Anthropic). All outputs reviewed manually.