Minimal alternative to the GNU envsubst utility. Substitutes environment variables using ${VAR} / $VAR syntax in files matched by glob patterns. Uses the expansion syntax from the shellexpand crate, but does not support tilde (~) expansion.
Packaged as a distroless container for use in init containers and CI pipelines.
Unknown variables are left as ${VAR} literals by default.
docker run --rm \
-e DB_HOST=postgres \
-v $(pwd)/templates:/in:ro \
-v $(pwd)/rendered:/out \
ghcr.io/ramcguire/envsubst \
"/in/**/*.yaml" --output /outenvsubst [options] PATTERN [PATTERN...]
| Argument | Description |
|---|---|
PATTERN... |
One or more glob patterns matching input files |
-o, --output DIR |
Write substituted files to DIR, mirroring the input directory structure. Omit to write to stdout. |
-e, --env-file GLOB |
Load variables from files matching GLOB (.env syntax). Repeatable. Mutually exclusive with the real environment — when any -e flag is given, only the loaded files are consulted; system env vars are ignored. |
-s, --scope VAR |
Substitute only VAR. Repeatable. Variables outside the scope remain literal, even if they are available in the configured source. |
-f, --fail-on-missing |
Exit with code 1 if an in-scope referenced variable is unset. In this mode, no stdout or output files are written until every input has rendered successfully. |
-v, --verbose |
Print processed file paths and a summary of unresolved variables to stderr. |
| Mode | Source |
|---|---|
No -e flag |
Real process environment |
One or more -e flags |
.env files only — system env is not consulted |
The two modes are mutually exclusive by design. The recommended usage mode is to use -e where possible as it is more declarative of intent. If you need both .env support and real environment variables you can source the .env first or simply run the tool twice.
Without --scope, every ${VAR} / $VAR reference is eligible for substitution. Each --scope VAR flag adds one variable to a whitelist; unlisted references are retained literally and do not count as unresolved. A scoped variable must be a shell-style identifier: [A-Za-z_][A-Za-z0-9_]*.
--scope selects variables to substitute; it does not declare them required. --fail-on-missing only fails when a scoped variable is referenced and unset. An explicitly empty value is substituted as empty, not considered missing.
# Substitute from the real environment, write to /out
envsubst "templates/**/*.yaml" --output /out
# Substitute only deployment settings; leave placeholders for later tools intact
envsubst "config/*.conf" --scope DB_HOST --scope DB_PORT --fail-on-missing --output /out
# Multiple .env files (later files win on conflict)
envsubst "k8s/**/*.yaml" --env-file base.env --env-file override.env --output /out
# Glob for .env files; preview on stdout with verbose output
envsubst "templates/*.yaml" --env-file "envs/*.env" --verbosePublished to ghcr.io/ramcguire/envsubst. All images are multi-platform: linux/amd64, linux/arm64, linux/arm/v7.
Four variants are published, covering two Debian bases × two distroless tags:
| Tag | Base image |
|---|---|
latest (default) |
distroless/static-debian12:latest |
nonroot |
distroless/static-debian12:nonroot |
debian13 |
distroless/static-debian13:latest |
debian13-nonroot |
distroless/static-debian13:nonroot |
Each variant follows the same scheme. The table below uses the default (latest) variant; substitute the variant suffix for others (e.g. v0.1.0-nonroot, sha-abc123-debian13).
| Tag | Updated | Example |
|---|---|---|
latest / nonroot / debian13 / debian13-nonroot |
Every merge to main |
ghcr.io/ramcguire/envsubst:nonroot |
vX.Y.Z / vX.Y |
On semver release tag | ghcr.io/ramcguire/envsubst:v0.1.0 |
sha-XXXXXXX |
Every push | ghcr.io/ramcguire/envsubst:sha-abc1234-debian13-nonroot |