0Pricing
DevOps Bootcamp · Lesson

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.conf

Quoted 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 $VAR immediately
  • << 'EOF' (quoted) — content is literal; expansion deferred to envsubst
#!/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 shell

Combining 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} — use default if VAR is unset or empty
  • ${VAR:?error message} — abort with an error if VAR is 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_HOST from 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 envsubst and grep the output for remaining ${ patterns
  • Use set -u in 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 reaches envsubst intact — 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 exec the 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

  1. Writing Lean Dockerfiles and Shell Entrypoints
  2. Templating Configs with envsubst and heredocs
  3. Scripting Cloud Resources via CLI and jq
  4. Health Probes, Readiness Gates, and Wait Loops
← Back to DevOps Bootcamp