Multi Environment Config Generator with Bash Templates
AI generated
$_
#!/
Bash · Configuration · DevOps · Real World
Multi Environment Config Generator
one template, many stages, zero manual copies

A config generator replaces hand maintained copies of .env files and server configurations with a single template per format, combined with clearly separated variables per environment. This article shows how to build such a config generator with envsubst, strict secrets separation, and a validation stage in Bash, so development, staging, and production never drift apart again.

18 min read envsubst · templates · .env · docker-compose Bash 4.x · 5.x · gettext-base

1. Why a config generator for multiple environments

Once a project runs more than one environment, a collection of nearly identical configuration files appears almost automatically: .env.dev, .env.staging, .env.production, each maintained by hand and needing an update in three places at once for every change. A config generator solves this problem by maintaining exactly one template per file format and feeding the actual values per environment from separate variable files.

The benefit of a config generator shows especially when a new configuration option is added: instead of maintaining three files in parallel, the variable is added once in the template, and the config generator automatically distributes it across every environment. Errors from forgotten copies, such as a production file that does not know about a new variable, disappear structurally.

In this article, a config generator is built on top of envsubst, the GNU gettext tool for substituting environment variables in text files. It ships preinstalled on most Linux distributions and requires no additional runtime, which makes the config generator ready to use immediately even in minimal container images.

2. Core principle: template, variables, overrides

The core principle of every config generator consists of three layers: a template with placeholders in the form ${VARIABLE_NAME}, a base file with shared values for all environments, and an environment specific override file that only contains the diverging values. This layering keeps redundancy to a minimum, since shared values like the application name only need to be maintained once.

A config generator loads the base values first when invoked, then the environment specific overrides, so that later values overwrite earlier ones. This order matters: reversing it could allow production specific values to be accidentally overwritten by generic defaults, which in practice leads to hard to trace misconfigurations.

3. Template syntax with envsubst

envsubst replaces every ${VARIABLE} or $VARIABLE reference in an input file with the current value of the environment variable of the same name. A config generator uses this tool because it is considerably more robust than custom sed replacements and automatically handles special characters in values, such as slashes or ampersands, that would otherwise need escaping with sed.

It is important to restrict envsubst to exactly the needed variables using the argument envsubst '$DB_HOST,$DB_PORT,$APP_ENV'. Without this restriction, the config generator would accidentally replace every existing shell variable in the process, including technical variables like $PATH, if the template happened to contain a similar pattern.


#!/usr/bin/env bash
# app.env.template — placeholder file processed by envsubst
APP_ENV=${APP_ENV}
APP_DEBUG=${APP_DEBUG}
DB_HOST=${DB_HOST}
DB_PORT=${DB_PORT}
DB_NAME=${DB_NAME}
CACHE_DRIVER=${CACHE_DRIVER}
API_BASE_URL=${API_BASE_URL}

4. Structuring variable files per environment

A proven directory layout for a config generator clearly separates base values and environment specific overrides: config/base.env for shared values, config/dev.env, config/staging.env, and config/production.env for the respective differences. Each of these files contains only classic KEY=VALUE lines, which can be safely loaded into environment variables with set -a; source file.env; set +a.

The config generator should be defensive when loading each variable file: skip comment lines starting with #, ignore empty lines, and abort immediately if required variables are still missing after loading all layers, instead of continuing with an empty variable in the generated result.


#!/usr/bin/env bash
# load-env-layers.sh — load base config, then environment-specific overrides
set -euo pipefail

load_layer() {
  local file="$1"
  if [[ -f "$file" ]]; then
    echo "[LOAD] $file" >&2
    set -a
    # shellcheck disable=SC1090
    source "$file"
    set +a
  else
    echo "[SKIP] $file not found" >&2
  fi
}

target_env="${1:?Usage: load-env-layers.sh <dev|staging|production>}"

load_layer "config/base.env"
load_layer "config/${target_env}.env"

: "${DB_HOST:?DB_HOST missing after loading all layers}"
: "${DB_NAME:?DB_NAME missing after loading all layers}"

5. Handling secrets separately from normal variables

Database passwords, API keys, and other secrets do not belong in the same variable files as normal configuration values, even if both end up in the same generated result eventually. A clean config generator loads secrets from a separate, unversioned file or directly from a secrets manager like Vault or AWS Secrets Manager, while the remaining variables can live in the git repository as usual.

This separation allows template files and non sensitive variable files to be versioned publicly in the repository, while the config generator only pulls secrets from a protected source at runtime. That way, the full history of configuration changes stays traceable, without a password ever ending up in the commit log.


#!/usr/bin/env bash
# load-secrets.sh — pull secrets separately, never commit them
set -euo pipefail

target_env="${1:?Usage: load-secrets.sh <dev|staging|production>}"
readonly SECRETS_FILE="/etc/mironsoft/secrets/${target_env}.env"

if [[ ! -f "$SECRETS_FILE" ]]; then
  echo "[ERROR] Secrets file not found: ${SECRETS_FILE}" >&2
  echo "        This file must be provisioned outside of git." >&2
  exit 1
fi

if [[ "$(stat -c '%a' "$SECRETS_FILE")" != "600" ]]; then
  echo "[ERROR] Secrets file has unsafe permissions, expected 600" >&2
  exit 1
fi

set -a
# shellcheck disable=SC1090
source "$SECRETS_FILE"
set +a

6. Validating the generated configuration

A config generator that produces a syntactically broken configuration is worse than no generator at all, because the error often only becomes visible once the application starts. Every generated file should therefore be validated right after generation: for .env files a simple regex check for valid KEY=VALUE lines is enough, for docker-compose.yml it is worth calling docker compose config, which parses the file without starting anything.

An additional, often overlooked validation step in the config generator: checking whether unresolved placeholders like ${UNDEFINED_VAR} are still left in the result after substitution. This almost always points to a missing variable in one of the layers and should abort the entire generation run with a clear error.


#!/usr/bin/env bash
# validate-generated-config.sh — catch broken output before deployment
set -euo pipefail

file="$1"

# Detect leftover, unresolved placeholders
if grep -qE '\$\{[A-Z_]+\}' "$file"; then
  echo "[ERROR] Unresolved placeholders found in ${file}:" >&2
  grep -oE '\$\{[A-Z_]+\}' "$file" | sort -u >&2
  exit 1
fi

# Basic KEY=VALUE sanity check for .env files
if [[ "$file" == *.env ]]; then
  while IFS= read -r line; do
    [[ -z "$line" || "$line" == \#* ]] && continue
    if [[ ! "$line" =~ ^[A-Z_][A-Z0-9_]*=.*$ ]]; then
      echo "[ERROR] Invalid line in ${file}: ${line}" >&2
      exit 1
    fi
  done < "$file"
fi

echo "[OK] ${file} passed validation"

7. Generating multiple target files from one source

Real projects rarely need only a single configuration file. A complete config generator produces multiple output formats from the same variable base: an .env file for the application, a docker-compose.override.yml for local container adjustments, and an nginx.conf for the web server. The key advantage: all three files come from the same source of truth, so the database host or API base URL never drift apart between formats.

The config generator iterates over a list of template target file pairs and calls envsubst for each pair using the same loaded environment variables. This keeps the logic centralized and turns adding another format, such as a supervisord.conf, into a one line addition to the pair list.


#!/usr/bin/env bash
# generate-all.sh — render every target format from the same variable set
set -euo pipefail

declare -A targets=(
  [templates/app.env.template]="dist/app.env"
  [templates/docker-compose.override.yml.template]="dist/docker-compose.override.yml"
  [templates/nginx.conf.template]="dist/nginx.conf"
)

readonly VARS='$APP_ENV,$APP_DEBUG,$DB_HOST,$DB_PORT,$DB_NAME,$CACHE_DRIVER,$API_BASE_URL'

for template in "${!targets[@]}"; do
  output="${targets[$template]}"
  mkdir -p "$(dirname "$output")"
  envsubst "$VARS" < "$template" > "$output"
  bash validate-generated-config.sh "$output"
  echo "[OK] Generated ${output} from ${template}"
done

8. The config generator's command line interface

A well designed config generator needs a simple CLI that takes the target environment as an argument and orchestrates the whole flow: load base values, load overrides, load secrets, generate every target file, and validate. A single call like ./generate-config.sh production should be enough to produce the full set of configuration files for one environment.

For local development, an additional --dry-run flag in the config generator is worth having, which only displays the generated files instead of writing them. That way developers can check before an actual deployment whether the correct values for the chosen environment really flow through.

9. Config generator compared to alternatives

There are specialized configuration tools like Consul Template or Ansible templating that solve similar problems, but each requires additional infrastructure or agents. A config generator built on envsubst, by contrast, needs only a single command line tool that is practically preinstalled everywhere.

Approach Extra infrastructure Secrets separation Suited for
Manual .env copies None Often mixed together Very small projects
Bash config generator (envsubst) None, only gettext-base Explicitly separated Containers, deployment scripts
Consul Template Requires a Consul cluster Via Consul KV Dynamic service discovery setups
Ansible templating Ansible control node Via Vault integration Existing Ansible infrastructure

For teams who do not want to run additional infrastructure, a simple Bash based config generator remains the most pragmatic solution, precisely because it runs in every container build step without further preparation.

Mironsoft

Shell automation, configuration management, and deployment infrastructure

A config generator for all your environments?

We build a config generator that cleanly separates templates, environment variables, and secrets, and produces consistent configuration files for dev, staging, and production.

Script development

A custom config generator matching your stack

Secrets management

Clean separation of configuration and sensitive values

CI integration

Automatic generation and validation in your deployment pipeline

10. Summary

A config generator built on envsubst reduces the maintenance of multiple environments to a single template per format and clearly layered variable files. Base values, environment specific overrides, and separately handled secrets ensure every environment stays consistent, without anyone needing to keep multiple copies in sync.

Validating right after generation catches broken output before it leads to cryptic startup errors in an application. Whoever additionally builds the config generator to produce several target formats from the same data source eliminates an entire class of inconsistencies between application, container, and web server configuration.

Config generator with Bash templates, the essentials at a glance

Layering

Base values, environment specific overrides, and secrets are loaded in strict separation.

Template engine

envsubst replaces placeholders robustly, without needing custom sed escaping logic.

Validation

Unresolved placeholders and broken KEY=VALUE lines are caught before deployment.

Multiple formats

One data source feeds .env, docker-compose, and server configuration at once, with no drift.

11. FAQ: Multi environment config generator with Bash

1What is a config generator?
A script that produces configuration files per environment from a template and variables, instead of maintaining copies by hand.
2Why envsubst instead of sed?
It handles special characters robustly and is preinstalled on almost every Linux system.
3How are unwanted replacements prevented?
Through an explicit variable list passed to envsubst, instead of allowing every environment variable.
4How are secrets handled?
Separately from normal values, loaded from an unversioned file or a secrets manager.
5How is output validated?
Check for unresolved placeholders, then format specific checks like docker compose config.
6Multiple formats from one source?
Yes, via template target file pairs rendered from the same variable base.
7Missing required variable?
An explicit check with : "${VAR:?message}" aborts immediately instead of carrying an empty variable through.
8Order of base and overrides?
Base values first, then environment specific overrides, so later values can deliberately override earlier ones.
9Extra runtime environment needed?
No, envsubst belongs to gettext-base and is usually already present.
10Is --dry-run worth it?
Yes, so values can be checked before deployment without overwriting files.