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.
Table of contents
- 1. Why a config generator for multiple environments
- 2. Core principle: template, variables, overrides
- 3. Template syntax with envsubst
- 4. Structuring variable files per environment
- 5. Handling secrets separately from normal variables
- 6. Validating the generated configuration
- 7. Generating multiple target files from one source
- 8. The config generator's command line interface
- 9. Config generator compared to alternatives
- 10. Summary
- 11. FAQ
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.