Templating Configs with envsubst and heredocs
Generate runtime configuration from environment variables using envsubst and quoted heredocs.
Templating Configs with envsubst and heredocs is a free DevOps Bootcamp lesson on CoddyKit — lesson 2 of 4. You can read the complete lesson below for free — then practise it hands-on in the browser with a built-in code editor and a 24/7 AI tutor. It is part of the DevOps Bootcamp learning path, one of 4 lessons in the course, and your progress syncs across the web and the CoddyKit app.
Why Runtime Config Templating Matters
In DevOps and container workflows, configuration files like nginx.conf, prometheus.yml, and docker-compose.yml often need to change between environments — staging, production, DR. Hardcoding values creates drift and secrets exposure.
The solution is runtime config templating: ship a template with placeholders, then inject real values at startup from environment variables. This keeps your image immutable and your config auditable.
- No secrets baked into images
- Same artifact promoted across environments
- Config generated just before the process starts
Two complementary tools make this trivial in Bash: envsubst and quoted heredocs.
envsubst: The One-Liner Config Generator
envsubst is a small GNU utility that reads stdin, substitutes $VARIABLE and ${VARIABLE} placeholders with their values from the current environment, and writes to stdout.
It ships with the gettext package and is available in virtually every Linux distro and Docker base image.
- Works with any text format: NGINX, YAML, TOML, JSON, INI
- Does not evaluate shell syntax — only replaces variable references
- Safe: it will not execute commands inside the template
#!/usr/bin/env bash
# Install check (usually already present)
which envsubst || apt-get install -y gettext-base
# Minimal demo
export APP_PORT=8080
export APP_HOST=api.example.com
echo 'server { listen ${APP_PORT}; server_name ${APP_HOST}; }' | envsubst
# Output: server { listen 8080; server_name api.example.com; }Selective Variable Substitution
By default, envsubst replaces every $VAR it finds. This can clobber NGINX variables like $uri or $host — those are real NGINX directives, not your env vars.
Pass an explicit list of variables as the first argument to restrict substitution to only those names:
envsubst '$VAR1 $VAR2'The argument is a single-quoted string (so the shell does not expand it) containing the variable names you want replaced, separated by spaces or newlines.
#!/usr/bin/env bash
export APP_PORT=8080
export APP_HOST=api.example.com
# NGINX template contains both our vars AND nginx vars ($uri, $host)
TEMPLATE='server {
listen ${APP_PORT};
server_name ${APP_HOST};
location / {
proxy_set_header Host $host;
proxy_pass http://backend$uri;
}
}'
# Only substitute APP_PORT and APP_HOST — leave $host and $uri untouched
echo "$TEMPLATE" | envsubst '${APP_PORT} ${APP_HOST}'
Template Files on Disk
For real configs, store the template as a file (e.g., nginx.conf.template) alongside your Dockerfile. At container startup, run envsubst to produce the final config file before launching the daemon.
This is the canonical pattern used by the official NGINX Docker image.
#!/usr/bin/env bash
# File: nginx.conf.template
# (In practice this lives on disk; we write it here for demo purposes)
cat > /tmp/nginx.conf.template << 'TMPL'
server {
listen ${NGINX_PORT};
server_name ${SERVER_NAME};
root /var/www/${APP_ENV};
location / {
proxy_pass http://app:${APP_PORT};
}
}
TMPL
export NGINX_PORT=80
export SERVER_NAME=myapp.example.com
export APP_ENV=production
export APP_PORT=3000
# Generate final config
envsubst '${NGINX_PORT} ${SERVER_NAME} ${APP_ENV} ${APP_PORT}' \
< /tmp/nginx.conf.template \
> /tmp/nginx.conf
cat /tmp/nginx.confQuoted Heredocs: Inline Templates Without a Temp File
A quoted heredoc (using << 'EOF' with single quotes around the delimiter) prevents the shell from expanding variables or running command substitutions inside the block. The content is treated as a literal string.
This makes heredocs the perfect way to write a template inline and pipe it straight into envsubst — no intermediate file needed.
<< EOF(unquoted) — shell expands$VARimmediately<< 'EOF'(quoted) — content is literal; expansion deferred toenvsubst
#!/usr/bin/env bash
export DB_HOST=postgres.internal
export DB_PORT=5432
export DB_NAME=myapp_prod
# Quoted heredoc: shell does NOT expand $DB_HOST etc. yet
envsubst << 'EOF'
[database]
host = ${DB_HOST}
port = ${DB_PORT}
dbname = ${DB_NAME}
EOF
# Output uses actual env var values — expansion done by envsubst, not the shellCombining Heredocs with Output Redirection
Pipe a quoted heredoc through envsubst and redirect the result to a file in one expression. This is the cleanest idiom for generating config files in an entrypoint script.
Use selective substitution ('${VAR1} ${VAR2}') when the target format (Prometheus, NGINX, etc.) has its own $variable syntax to protect.
#!/usr/bin/env bash
# entrypoint.sh — Docker container entrypoint
set -euo pipefail
export PROM_PORT=${PROM_PORT:-9090}
export SCRAPE_INTERVAL=${SCRAPE_INTERVAL:-15s}
export TARGET_HOST=${TARGET_HOST:-localhost:8080}
envsubst '${PROM_PORT} ${SCRAPE_INTERVAL} ${TARGET_HOST}' << 'EOF' > /etc/prometheus/prometheus.yml
global:
scrape_interval: ${SCRAPE_INTERVAL}
evaluation_interval: ${SCRAPE_INTERVAL}
scrape_configs:
- job_name: 'app'
static_configs:
- targets: ['${TARGET_HOST}']
EOF
echo "[entrypoint] Prometheus config written on port ${PROM_PORT}"
exec prometheus --config.file=/etc/prometheus/prometheus.yml --web.listen-address=":${PROM_PORT}"Default Values and Validation Before Substitution
Never trust that all required variables are set. Use Bash parameter expansion to provide defaults or fail loudly:
${VAR:-default}— usedefaultifVARis unset or empty${VAR:?error message}— abort with an error ifVARis unset or empty
Set these before calling envsubst so the template always receives a concrete value or the script halts early with a helpful message.
#!/usr/bin/env bash
set -euo pipefail
# Required — abort if missing
: "${DATABASE_URL:?DATABASE_URL must be set}"
: "${SECRET_KEY:?SECRET_KEY must be set}"
# Optional with defaults
export APP_PORT=${APP_PORT:-8000}
export LOG_LEVEL=${LOG_LEVEL:-info}
export WORKERS=${WORKERS:-4}
envsubst '${DATABASE_URL} ${SECRET_KEY} ${APP_PORT} ${LOG_LEVEL} ${WORKERS}' \
< /app/config/app.conf.template \
> /app/config/app.conf
echo "[init] Config generated — port=${APP_PORT} workers=${WORKERS} log=${LOG_LEVEL}"Generating Multi-Section Configs with Multiple Heredocs
For complex configs built from logical sections, you can generate each section independently and concatenate them, or use a single heredoc that spans the whole file. Both approaches work — choose based on readability.
When sections are conditionally included (e.g., TLS block only if a cert path is set), the multi-heredoc approach with if blocks is cleaner.
#!/usr/bin/env bash
set -euo pipefail
export APP_HOST=${APP_HOST:-localhost}
export APP_PORT=${APP_PORT:-8080}
export TLS_CERT=${TLS_CERT:-}
export TLS_KEY=${TLS_KEY:-}
CONFIG_FILE=/tmp/app.conf
# Base section
envsubst '${APP_HOST} ${APP_PORT}' << 'BASE' > "$CONFIG_FILE"
[server]
host = ${APP_HOST}
port = ${APP_PORT}
BASE
# Conditional TLS section — only appended when cert is provided
if [[ -n "$TLS_CERT" && -n "$TLS_KEY" ]]; then
envsubst '${TLS_CERT} ${TLS_KEY}' << 'TLS' >> "$CONFIG_FILE"
[tls]
cert_file = ${TLS_CERT}
key_file = ${TLS_KEY}
TLS
echo "[init] TLS enabled"
else
echo "[init] TLS disabled (no cert/key provided)"
fi
cat "$CONFIG_FILE"Docker Entrypoint Pattern
The recommended Docker entrypoint pattern uses a shell script (docker-entrypoint.sh) to generate configs at startup, then hand off to the main process with exec. Using exec replaces the shell process with the daemon, so signals (SIGTERM, SIGINT) reach the daemon directly — critical for graceful shutdown.
Template files are added to the image at build time; values are injected at run time from docker run -e or Kubernetes env: / envFrom:.
#!/usr/bin/env bash
# docker-entrypoint.sh
set -euo pipefail
# Validate required env vars
for var in DATABASE_URL REDIS_URL SECRET_KEY; do
: "${!var:?$var is required}"
done
export APP_PORT=${APP_PORT:-8000}
export WORKERS=${WORKERS:-$(nproc)}
echo "[entrypoint] Generating configuration..."
envsubst '${DATABASE_URL} ${REDIS_URL} ${SECRET_KEY} ${APP_PORT} ${WORKERS}' \
< /app/config/settings.toml.template \
> /app/config/settings.toml
echo "[entrypoint] Starting server on port ${APP_PORT} with ${WORKERS} workers"
exec gunicorn app:application \
--bind "0.0.0.0:${APP_PORT}" \
--workers "${WORKERS}"Kubernetes ConfigMap + envsubst Pattern
In Kubernetes, environment variables are injected via env: or envFrom: in the Pod spec. Your container entrypoint calls envsubst to materialise configs before the process starts — no ConfigMap per environment needed.
This keeps environment-specific values in Kubernetes Secrets and ConfigMaps (for non-sensitive data), while the config template lives in the image. One image, many environments.
- Build:
COPY nginx.conf.template /etc/nginx/templates/ - Runtime: entrypoint runs
envsubst, writes/etc/nginx/nginx.conf - K8s injects:
APP_PORT,BACKEND_HOSTfrom a Secret/ConfigMap
Debugging envsubst: Finding Missing or Unresolved Variables
When a generated config contains literal ${VAR} instead of a value, the variable was not exported or not included in the substitution list. Use these techniques to debug:
printenv | sort— list all exported variables- Compare template placeholders against exported vars with
grep - Run
envsubstand grep the output for remaining${patterns - Use
set -uin the calling script so unset variable references in Bash code abort immediately
#!/usr/bin/env bash
set -euo pipefail
TEMPLATE=/tmp/app.conf.template
OUTPUT=/tmp/app.conf
# Write a demo template
cat > "$TEMPLATE" << 'EOF'
host=${DB_HOST}
port=${DB_PORT}
name=${DB_NAME}
EOF
export DB_HOST=db.internal
export DB_PORT=5432
# DB_NAME intentionally left unset
envsubst < "$TEMPLATE" > "$OUTPUT"
# Detect unresolved placeholders
if grep -qE '\$\{[A-Z_]+\}' "$OUTPUT"; then
echo "ERROR: unresolved placeholders found:"
grep -oE '\$\{[A-Z_]+\}' "$OUTPUT" | sort -u
exit 1
fi
echo "Config OK:"
cat "$OUTPUT"Knowledge Check: envsubst Selective Substitution
Consider an NGINX configuration template that contains both your application variable ${APP_PORT} and the native NGINX variable $uri. You run the following command:
envsubst < nginx.conf.template > nginx.conf
What is the result?
Lesson Recap: Templating Configs with envsubst and Heredocs
You now have a production-grade toolkit for runtime config generation in Bash:
- envsubst replaces
${VAR}placeholders in any text file using the current environment — no scripting, no special escaping - Selective substitution (
envsubst '${VAR1} ${VAR2}') protects native variables in NGINX, Prometheus, and similar tools from accidental replacement - Quoted heredocs (
<< 'EOF') defer shell expansion so the template content reachesenvsubstintact — no temp files required - Validate before you substitute: use
${VAR:?message}to abort on missing required vars and${VAR:-default}for optional ones - Docker entrypoint pattern: generate configs at container startup, then
execthe daemon so signals are handled correctly - Debug unresolved placeholders by grepping the output for remaining
${patterns before the process starts
These patterns keep your container images immutable, your secrets out of source control, and your configs consistent across every environment.
Frequently asked questions
Is the “Templating Configs with envsubst and heredocs” lesson free?
Yes — the full text of “Templating Configs with envsubst and heredocs” is free to read here on the web, and the DevOps Bootcamp course includes 4 lessons in total. To practise it interactively (a built-in code editor and a 24/7 AI tutor) and unlock the rest of the DevOps Bootcamp course, upgrade to CoddyKit PRO.
What will I learn in “Templating Configs with envsubst and heredocs”?
Generate runtime configuration from environment variables using envsubst and quoted heredocs. You practise DevOps Bootcamp with hands-on code you run directly in the browser, and a 24/7 AI tutor answers your questions as you work through the lesson.
Do I need any experience to start DevOps Bootcamp?
No prior experience is required. DevOps Bootcamp on CoddyKit is structured for beginners through advanced learners; this is — lesson 2 of 4, so you can start here or from the beginning and move at your own pace.
How long does the “Templating Configs with envsubst and heredocs” lesson take?
Most CoddyKit lessons take about 5–10 minutes. Each one is bite-sized and interactive, so you make steady progress and pick up exactly where you left off across the web and the app.
Can I write and run code in this DevOps Bootcamp lesson?
Yes. Every DevOps Bootcamp lesson includes a built-in code editor, so you write and run real code right in your browser and get instant AI feedback — no local setup required.
All lessons in this course
- Writing Lean Dockerfiles and Shell Entrypoints
- Templating Configs with envsubst and heredocs
- Scripting Cloud Resources via CLI and jq
- Health Probes, Readiness Gates, and Wait Loops