Learn tmux from scratch — sessions, windows, panes, and scripting — then build a Go CLI tool that …
Config Templating: From envsubst to Go Config Templating: From envsubst to Go

Summary
Every environment needs different config. Different database hosts, different API keys, different feature flags. Copy-pasting config files for dev, staging, and prod is how things break: someone forgets to update the staging database host, and your staging app quietly writes to the production database for three days. Config templating solves this. We will start with envsubst (one command, zero dependencies), then build the same thing in Go with text/template where we get conditionals, defaults, and validation.

Prerequisites
- A Linux system (native, WSL, or SSH)
- Go 1.21+ installed
envsubstinstalled (apt install gettext-baseon Debian/Ubuntu)
Step 1: envsubst Basics – Variable Substitution
envsubst does one thing: it reads text from stdin, replaces ${VAR} patterns with environment variable values, and writes the result to stdout. That is the entire feature set.
Set some environment variables:
export DB_HOST=localhost
export DB_PORT=5432
export DB_NAME=myapp_dev
Create a template file called app.conf.tpl:
database_host = ${DB_HOST}
database_port = ${DB_PORT}
database_name = ${DB_NAME}
log_level = debug
Run envsubst:
envsubst < app.conf.tpl > app.conf
cat app.conf
Expected output:
database_host = localhost
database_port = 5432
database_name = myapp_dev
log_level = debug
That is config templating in its simplest form. The template has placeholders. envsubst fills them in from the environment. The output is a valid config file.
The Missing Variable Problem
Now unset one of the variables and run it again:
unset DB_NAME
envsubst < app.conf.tpl > app.conf
cat app.conf
Expected output:
database_host = localhost
database_port = 5432
database_name =
log_level = debug
The database_name line is now empty. No warning. No error. envsubst exits with code 0 like nothing happened.
In production, this means your app starts with a blank database name. It does not crash immediately. It crashes 5 minutes later with a confusing connection error, and you spend an hour looking at the wrong thing.
Partial Substitution
You can tell envsubst to only replace specific variables. Everything else stays as literal text:
export DB_HOST=localhost
export DB_PORT=5432
envsubst '$DB_HOST $DB_PORT' < app.conf.tpl > app.conf
cat app.conf
Expected output:
database_host = localhost
database_port = 5432
database_name = ${DB_NAME}
log_level = debug
Only $DB_HOST and $DB_PORT were replaced. ${DB_NAME} stays as literal text in the output. This is useful when your template contains dollar signs that are not meant to be substituted, for example nginx variables like $uri or shell scripts that use $1.
Deepen your understanding in Jenkinsfile with envsubst: Simplifying CI/CD Pipeline Configuration
Step 2: envsubst for Real Configs (Nginx, Docker Compose)
envsubst works with any text format. Here are two common uses.
Nginx Template
Create nginx.conf.tpl:
server {
listen ${NGINX_PORT};
server_name ${SERVER_NAME};
location / {
proxy_pass http://${BACKEND_HOST}:${BACKEND_PORT};
}
}
Generate the config and reload nginx:
export NGINX_PORT=80
export SERVER_NAME=api.example.com
export BACKEND_HOST=127.0.0.1
export BACKEND_PORT=8080
envsubst < nginx.conf.tpl > /etc/nginx/conf.d/app.conf
nginx -t && nginx -s reload
This pattern is everywhere in Docker images. The entrypoint script runs envsubst to generate the nginx config from environment variables, then starts nginx. The official nginx Docker image even has built-in support for this.
Docker Compose Template
Create docker-compose.tpl.yml:
services:
app:
image: myapp:${APP_VERSION}
environment:
DATABASE_URL: postgresql://${DB_USER}:${DB_PASS}@${DB_HOST}:5432/${DB_NAME}
ports:
- "${APP_PORT}:8080"
In CI/CD, this is a common pattern. The pipeline sets environment variables from secrets, runs envsubst to generate the final compose file, then runs docker-compose/">docker compose up:
export APP_VERSION=1.4.2
export DB_USER=app
export DB_PASS=secret123
export DB_HOST=db.internal
export DB_NAME=myapp
export APP_PORT=3000
envsubst < docker-compose.tpl.yml > docker-compose.yml
docker compose up -d
The Limits of envsubst
envsubst cannot do conditionals. You cannot say “if production, enable SSL.” It cannot do loops. You cannot say “for each backend server, add a line.” It cannot set defaults. If a variable is unset, the value is empty.
For anything beyond simple substitution, you need something more powerful. That is where Go’s text/template comes in.
Explore this further in Nginx Log Analysis: From grep to a Go Log Parser
Step 3: Go text/template – The Basics
Go’s text/template package is part of the standard library. No external dependencies. It supports variables, conditionals, loops, custom functions, and more.
Let us build a Go program that does what envsubst does.
Create the project:
mkdir confgen && cd confgen
go mod init confgen
Create a template file app.conf.tpl:
database_host = {{.DB_HOST}}
database_port = {{.DB_PORT}}
database_name = {{.DB_NAME}}
log_level = {{.LogLevel}}
Go templates use {{.FieldName}} instead of ${VAR}. The dot (.) refers to the data passed to the template.
main.go (first attempt – has a bug):
package main
import (
"os"
"text/template"
)
func main() {
tmpl, err := template.ParseFiles("app.conf.tpl")
if err != nil {
panic(err)
}
data := map[string]string{
"DB_HOST": "localhost",
"DB_PORT": "5432",
"DB_NAME": "myapp_dev",
"LogLevel": "debug",
}
err = tmpl.Execute(os.Stdout, data)
if err != nil {
panic(err)
}
}
go run main.go
Expected output:
database_host = localhost
database_port = 5432
database_name = myapp_dev
log_level = debug
This works. Now let us see what happens when a key is missing.
The Bug: Silent <no value>
Remove LogLevel from the data map:
package main
import (
"os"
"text/template"
)
func main() {
tmpl, err := template.ParseFiles("app.conf.tpl")
if err != nil {
panic(err)
}
data := map[string]string{
"DB_HOST": "localhost",
"DB_PORT": "5432",
"DB_NAME": "myapp_dev",
// LogLevel is missing
}
err = tmpl.Execute(os.Stdout, data)
if err != nil {
panic(err)
}
}
go run main.go
Expected output:
database_host = localhost
database_port = 5432
database_name = myapp_dev
log_level = <no value>
Go’s template engine does not return an error. It silently inserts the string <no value> where the missing key should be. If this were an nginx config, you would get proxy_pass http://<no value>:8080; and nginx would try to resolve the hostname <no value>. That is worse than empty because it is a valid-looking but completely wrong hostname.
The Fix: Fail on Missing Keys
Set missingkey=error on the template:
package main
import (
"os"
"text/template"
)
func main() {
tmpl, err := template.New("app.conf.tpl").
Option("missingkey=error").
ParseFiles("app.conf.tpl")
if err != nil {
panic(err)
}
data := map[string]string{
"DB_HOST": "localhost",
"DB_PORT": "5432",
"DB_NAME": "myapp_dev",
// LogLevel is missing
}
err = tmpl.Execute(os.Stdout, data)
if err != nil {
panic(err)
}
}
go run main.go
Expected output:
panic: template: app.conf.tpl:4:16: executing "app.conf.tpl" at <.LogLevel>:
map has no entry for key "LogLevel"
Now the program crashes immediately with a clear error message. It tells you the template name, the line number, and the missing key. Fail fast is always better than silent corruption.
Discover related concepts in CI Pipeline Basics: From Shell Scripts to a Go Build Runner
Option("missingkey=error") in Go templates. The default behavior of inserting <no value> silently is almost never what you want.Step 4: Conditionals and Defaults in Go Templates
This is where Go templates pull ahead of envsubst. You can add logic to your templates.
Create app.conf.tpl:
database_host = {{.DB_HOST}}
database_port = {{.DB_PORT | default "5432"}}
log_level = {{if eq .Env "production"}}warn{{else}}debug{{end}}
{{- if .SSL_ENABLED}}
ssl_cert = /etc/ssl/certs/app.crt
ssl_key = /etc/ssl/private/app.key
{{- end}}
Go’s text/template does not have a built-in default function. You need to add it yourself using template.FuncMap.
main.go (first attempt – has a bug):
package main
import (
"os"
"text/template"
)
func main() {
funcMap := template.FuncMap{
"default": func(defVal, actual interface{}) interface{} {
if actual == nil || actual == "" {
return defVal
}
return actual
},
}
tmplText := `database_host = {{.DB_HOST}}
database_port = {{.DB_PORT | default "5432"}}
log_level = {{if eq .Env "production"}}warn{{else}}debug{{end}}
{{- if .SSL_ENABLED}}
ssl_cert = /etc/ssl/certs/app.crt
ssl_key = /etc/ssl/private/app.key
{{- end}}`
tmpl, err := template.New("config").
Funcs(funcMap).
Option("missingkey=error").
Parse(tmplText)
if err != nil {
panic(err)
}
data := map[string]interface{}{
"DB_HOST": "prod-db.internal",
"DB_PORT": "",
"Env": "production",
"SSL_ENABLED": "false",
}
err = tmpl.Execute(os.Stdout, data)
if err != nil {
panic(err)
}
}
go run main.go
Expected output:
database_host = prod-db.internal
database_port = 5432
log_level = warn
ssl_cert = /etc/ssl/certs/app.crt
ssl_key = /etc/ssl/private/app.key
Wait. SSL_ENABLED is set to "false", but the SSL block still rendered. Why?
The Bug: String “false” Is Truthy
In Go templates, the {{if}} action checks for “truthiness.” A non-empty string is truthy. The string "false" is not empty, so it is truthy. It does not matter that the string says “false”. Go does not interpret string contents as booleans.
This bug is subtle. Someone sets SSL_ENABLED=false in their environment, thinking they are disabling SSL. But the template sees a non-empty string and enables it anyway.
The Fix: Convert Strings to Booleans
Add a toBool function that properly interprets string values:
package main
import (
"fmt"
"os"
"strings"
"text/template"
)
func main() {
funcMap := template.FuncMap{
"default": func(defVal, actual interface{}) interface{} {
if actual == nil || actual == "" {
return defVal
}
return actual
},
"toBool": func(s interface{}) bool {
switch v := s.(type) {
case bool:
return v
case string:
lower := strings.ToLower(strings.TrimSpace(v))
return lower == "true" || lower == "1" || lower == "yes"
default:
return false
}
},
}
tmplText := `database_host = {{.DB_HOST}}
database_port = {{.DB_PORT | default "5432"}}
log_level = {{if eq .Env "production"}}warn{{else}}debug{{end}}
{{- if toBool .SSL_ENABLED}}
ssl_cert = /etc/ssl/certs/app.crt
ssl_key = /etc/ssl/private/app.key
{{- end}}`
tmpl, err := template.New("config").
Funcs(funcMap).
Option("missingkey=error").
Parse(tmplText)
if err != nil {
panic(err)
}
data := map[string]interface{}{
"DB_HOST": "prod-db.internal",
"DB_PORT": "",
"Env": "production",
"SSL_ENABLED": "false",
}
err = tmpl.Execute(os.Stdout, data)
if err != nil {
panic(err)
}
fmt.Println()
fmt.Println("---")
// Now with SSL actually enabled
data["SSL_ENABLED"] = "true"
err = tmpl.Execute(os.Stdout, data)
if err != nil {
panic(err)
}
}
go run main.go
Expected output:
database_host = prod-db.internal
database_port = 5432
log_level = warn
---
database_host = prod-db.internal
database_port = 5432
log_level = warn
ssl_cert = /etc/ssl/certs/app.crt
ssl_key = /etc/ssl/private/app.key
Now "false", "0", and "no" all correctly evaluate to false. Only "true", "1", and "yes" enable the SSL block. The toBool function handles the type conversion so the template logic stays clean.
Uncover more details in Terraform From Scratch: Provision AWS Infrastructure Step by Step
Step 5: Build a Config Generator for Multiple Environments
Individual templates and inline data maps are fine for learning. In production, you need a tool that reads a template, reads environment-specific values from a file, merges them, and writes the output. Let us build that.
The tool takes three inputs:
- A template file (any format)
- A values file (YAML) with defaults and per-environment overrides
- An environment name
Create the project:
mkdir confgen-tool && cd confgen-tool
go mod init confgen-tool
go get gopkg.in/yaml.v3
Create values.yaml:
default:
DB_PORT: 5432
LOG_LEVEL: debug
SSL_ENABLED: false
dev:
DB_HOST: localhost
DB_NAME: myapp_dev
DB_USER: dev_user
staging:
DB_HOST: staging-db.internal
DB_NAME: myapp_staging
DB_USER: staging_user
LOG_LEVEL: info
prod:
DB_HOST: prod-db.internal
DB_NAME: myapp_prod
DB_USER: prod_user
LOG_LEVEL: warn
SSL_ENABLED: true
Create app.conf.tpl:
database_host = {{.DB_HOST}}
database_port = {{.DB_PORT}}
database_name = {{.DB_NAME}}
database_user = {{.DB_USER}}
log_level = {{.LOG_LEVEL}}
ssl_enabled = {{.SSL_ENABLED}}
main.go (first attempt – has a bug):
package main
import (
"flag"
"fmt"
"os"
"text/template"
"gopkg.in/yaml.v3"
)
func main() {
tmplFile := flag.String("template", "", "path to template file")
valuesFile := flag.String("values", "", "path to values YAML file")
env := flag.String("env", "", "environment name (dev, staging, prod)")
flag.Parse()
if *tmplFile == "" || *valuesFile == "" || *env == "" {
fmt.Fprintln(os.Stderr, "Usage: confgen --template FILE --values FILE --env ENV")
os.Exit(1)
}
// Read values file
valuesData, err := os.ReadFile(*valuesFile)
if err != nil {
fmt.Fprintf(os.Stderr, "Error reading values file: %v\n", err)
os.Exit(1)
}
// Parse YAML into map of maps
var allValues map[string]map[string]interface{}
err = yaml.Unmarshal(valuesData, &allValues)
if err != nil {
fmt.Fprintf(os.Stderr, "Error parsing values YAML: %v\n", err)
os.Exit(1)
}
// Merge: start with defaults, then overlay environment-specific values
merged := make(map[string]interface{})
if defaults, ok := allValues["default"]; ok {
for k, v := range defaults {
merged[k] = v
}
}
envValues, ok := allValues[*env]
if !ok {
fmt.Fprintf(os.Stderr, "Error: environment %q not found in values file\n", *env)
os.Exit(1)
}
for k, v := range envValues {
merged[k] = v
}
// Parse and execute template
tmpl, err := template.New("config").
Option("missingkey=error").
ParseFiles(*tmplFile)
if err != nil {
fmt.Fprintf(os.Stderr, "Error parsing template: %v\n", err)
os.Exit(1)
}
err = tmpl.ExecuteTemplate(os.Stdout, *tmplFile, merged)
if err != nil {
fmt.Fprintf(os.Stderr, "Error executing template: %v\n", err)
os.Exit(1)
}
}
go run main.go --template app.conf.tpl --values values.yaml --env prod
Expected output:
database_host = prod-db.internal
database_port = 5432
database_name = myapp_prod
database_user = prod_user
log_level = warn
ssl_enabled = true
That looks right. Now try comparing a value in the template:
Update app.conf.tpl to use a conditional:
database_host = {{.DB_HOST}}
database_port = {{if eq .DB_PORT "5432"}}default{{else}}{{.DB_PORT}}{{end}}
database_name = {{.DB_NAME}}
database_user = {{.DB_USER}}
log_level = {{.LOG_LEVEL}}
ssl_enabled = {{.SSL_ENABLED}}
go run main.go --template app.conf.tpl --values values.yaml --env prod
The Bug: Type Mismatch in Comparisons
Expected output:
Error executing template: template: app.conf.tpl:2:26: executing "app.conf.tpl"
at <eq .DB_PORT "5432">: error calling eq: incompatible types for comparison
The program crashes. YAML parsed 5432 as an integer (type int), but the template is comparing it to the string "5432". Go templates do not auto-convert types. An int and a string cannot be compared with eq.
This is a common trap when loading values from YAML. Numbers become int, booleans become bool, and everything else becomes string. Your template does not know which type it is getting.
The Fix: Convert All Values to Strings
The simplest fix is to convert all values to strings before passing them to the template. Config files are text. The values will be printed as text. Strings are the right type.
Replace the merge section:
package main
import (
"flag"
"fmt"
"os"
"text/template"
"gopkg.in/yaml.v3"
)
func main() {
tmplFile := flag.String("template", "", "path to template file")
valuesFile := flag.String("values", "", "path to values YAML file")
env := flag.String("env", "", "environment name (dev, staging, prod)")
flag.Parse()
if *tmplFile == "" || *valuesFile == "" || *env == "" {
fmt.Fprintln(os.Stderr, "Usage: confgen --template FILE --values FILE --env ENV")
os.Exit(1)
}
// Read values file
valuesData, err := os.ReadFile(*valuesFile)
if err != nil {
fmt.Fprintf(os.Stderr, "Error reading values file: %v\n", err)
os.Exit(1)
}
// Parse YAML into map of maps
var allValues map[string]map[string]interface{}
err = yaml.Unmarshal(valuesData, &allValues)
if err != nil {
fmt.Fprintf(os.Stderr, "Error parsing values YAML: %v\n", err)
os.Exit(1)
}
// Merge: start with defaults, then overlay environment-specific values
// Convert everything to strings to avoid type mismatch in templates
merged := make(map[string]string)
if defaults, ok := allValues["default"]; ok {
for k, v := range defaults {
merged[k] = fmt.Sprintf("%v", v)
}
}
envValues, ok := allValues[*env]
if !ok {
fmt.Fprintf(os.Stderr, "Error: environment %q not found in values file\n", *env)
os.Exit(1)
}
for k, v := range envValues {
merged[k] = fmt.Sprintf("%v", v)
}
// Parse and execute template
tmpl, err := template.New("config").
Option("missingkey=error").
ParseFiles(*tmplFile)
if err != nil {
fmt.Fprintf(os.Stderr, "Error parsing template: %v\n", err)
os.Exit(1)
}
err = tmpl.ExecuteTemplate(os.Stdout, *tmplFile, merged)
if err != nil {
fmt.Fprintf(os.Stderr, "Error executing template: %v\n", err)
os.Exit(1)
}
}
fmt.Sprintf("%v", v) converts any value to its string representation. 5432 becomes "5432". true becomes "true". Now all comparisons in the template work because everything is a string.
go run main.go --template app.conf.tpl --values values.yaml --env prod
Expected output:
database_host = prod-db.internal
database_port = default
database_name = myapp_prod
database_user = prod_user
log_level = warn
ssl_enabled = true
The eq .DB_PORT "5432" comparison works now. DB_PORT is "5432" (string), and it matches. The template prints “default” instead of the port number.
Let us also verify with a different environment:
go run main.go --template app.conf.tpl --values values.yaml --env dev
Expected output:
database_host = localhost
database_port = default
database_name = myapp_dev
database_user = dev_user
log_level = debug
ssl_enabled = false
The merge works correctly. dev inherits DB_PORT, LOG_LEVEL, and SSL_ENABLED from default, and provides its own DB_HOST, DB_NAME, and DB_USER.
Journey deeper into this topic with How to Replace Text in Multiple Files with Sed: A Step-by-Step Guide
Step 6: Add Validation and Dry Run
A config generator that only produces output is not enough for CI/CD. You need two more features:
- Validation – check that all template variables have values before generating anything
- Dry run – show what the output would be without writing to a file
Template Variable Extraction
Go’s text/template package parses templates into an AST (abstract syntax tree). We can walk the AST to find all variable references.
Here is the complete tool with validation and dry run:
main.go:
package main
import (
"flag"
"fmt"
"os"
"strings"
"text/template"
"text/template/parse"
"gopkg.in/yaml.v3"
)
func main() {
tmplFile := flag.String("template", "", "path to template file")
valuesFile := flag.String("values", "", "path to values YAML file")
env := flag.String("env", "", "environment name (dev, staging, prod)")
validate := flag.Bool("validate", false, "validate template variables against values")
dryRun := flag.Bool("dry-run", false, "print output to stdout without writing")
output := flag.String("output", "", "output file path (default: stdout)")
flag.Parse()
if *tmplFile == "" || *valuesFile == "" || *env == "" {
fmt.Fprintln(os.Stderr, "Usage: confgen --template FILE --values FILE --env ENV [--validate] [--dry-run] [--output FILE]")
os.Exit(1)
}
// Read and parse values
valuesData, err := os.ReadFile(*valuesFile)
if err != nil {
fmt.Fprintf(os.Stderr, "Error reading values file: %v\n", err)
os.Exit(1)
}
var allValues map[string]map[string]interface{}
err = yaml.Unmarshal(valuesData, &allValues)
if err != nil {
fmt.Fprintf(os.Stderr, "Error parsing values YAML: %v\n", err)
os.Exit(1)
}
// Merge defaults + environment values, convert to strings
merged := make(map[string]string)
if defaults, ok := allValues["default"]; ok {
for k, v := range defaults {
merged[k] = fmt.Sprintf("%v", v)
}
}
envValues, ok := allValues[*env]
if !ok {
fmt.Fprintf(os.Stderr, "Error: environment %q not found in values file\n", *env)
os.Exit(1)
}
for k, v := range envValues {
merged[k] = fmt.Sprintf("%v", v)
}
// Parse template
tmpl, err := template.New("config").
Option("missingkey=error").
ParseFiles(*tmplFile)
if err != nil {
fmt.Fprintf(os.Stderr, "Error parsing template: %v\n", err)
os.Exit(1)
}
// Validation mode
if *validate {
vars := extractTemplateVars(tmpl)
missing := 0
fmt.Printf("Validating template: %s\n", *tmplFile)
fmt.Printf("Environment: %s\n\n", *env)
for _, v := range vars {
if val, ok := merged[v]; ok {
// Check if it came from defaults or the env-specific section
source := *env
if _, inEnv := envValues[v]; !inEnv {
source = "default"
}
fmt.Printf(" OK: %s = %s (from %s)\n", v, val, source)
} else {
fmt.Printf(" MISSING: %s (referenced in template, not in values)\n", v)
missing++
}
}
fmt.Println()
if missing > 0 {
fmt.Printf("Validation FAILED: %d missing variable(s)\n", missing)
os.Exit(1)
}
fmt.Println("Validation PASSED: all variables have values")
os.Exit(0)
}
// Execute template
var buf strings.Builder
err = tmpl.ExecuteTemplate(&buf, *tmplFile, merged)
if err != nil {
fmt.Fprintf(os.Stderr, "Error executing template: %v\n", err)
os.Exit(1)
}
result := buf.String()
// Dry run mode
if *dryRun {
fmt.Println("--- DRY RUN (would generate) ---")
fmt.Print(result)
fmt.Println("--- END DRY RUN ---")
os.Exit(0)
}
// Write output
if *output != "" {
err = os.WriteFile(*output, []byte(result), 0644)
if err != nil {
fmt.Fprintf(os.Stderr, "Error writing output file: %v\n", err)
os.Exit(1)
}
fmt.Fprintf(os.Stderr, "Generated: %s\n", *output)
} else {
fmt.Print(result)
}
}
// extractTemplateVars walks the template AST and returns all field names
// referenced with the {{.FieldName}} pattern.
func extractTemplateVars(tmpl *template.Template) []string {
seen := make(map[string]bool)
var vars []string
for _, t := range tmpl.Templates() {
if t.Tree == nil {
continue
}
walkTree(t.Tree.Root, &vars, seen)
}
return vars
}
func walkTree(node parse.Node, vars *[]string, seen map[string]bool) {
if node == nil {
return
}
switch n := node.(type) {
case *parse.ListNode:
if n == nil {
return
}
for _, child := range n.Nodes {
walkTree(child, vars, seen)
}
case *parse.ActionNode:
if n.Pipe != nil {
walkPipe(n.Pipe, vars, seen)
}
case *parse.IfNode:
if n.Pipe != nil {
walkPipe(n.Pipe, vars, seen)
}
walkTree(n.List, vars, seen)
walkTree(n.ElseList, vars, seen)
case *parse.RangeNode:
if n.Pipe != nil {
walkPipe(n.Pipe, vars, seen)
}
walkTree(n.List, vars, seen)
walkTree(n.ElseList, vars, seen)
case *parse.WithNode:
if n.Pipe != nil {
walkPipe(n.Pipe, vars, seen)
}
walkTree(n.List, vars, seen)
walkTree(n.ElseList, vars, seen)
}
}
func walkPipe(pipe *parse.PipeNode, vars *[]string, seen map[string]bool) {
for _, cmd := range pipe.Cmds {
for _, arg := range cmd.Args {
if field, ok := arg.(*parse.FieldNode); ok {
for _, ident := range field.Ident {
if !seen[ident] {
seen[ident] = true
*vars = append(*vars, ident)
}
}
}
}
}
}
Use the same app.conf.tpl from Step 5 (the version without the conditional, to keep things clear):
database_host = {{.DB_HOST}}
database_port = {{.DB_PORT}}
database_name = {{.DB_NAME}}
database_user = {{.DB_USER}}
log_level = {{.LOG_LEVEL}}
ssl_enabled = {{.SSL_ENABLED}}
Validation Mode
go run main.go --template app.conf.tpl --values values.yaml --env staging --validate
Expected output:
Validating template: app.conf.tpl
Environment: staging
OK: DB_HOST = staging-db.internal (from staging)
OK: DB_PORT = 5432 (from default)
OK: DB_NAME = myapp_staging (from staging)
OK: DB_USER = staging_user (from staging)
OK: LOG_LEVEL = info (from staging)
OK: SSL_ENABLED = false (from default)
Validation PASSED: all variables have values
Every variable in the template has a value. The tool also shows where each value comes from – the environment-specific section or the defaults.
Now add a variable to the template that does not exist in the values file. Add this line to app.conf.tpl:
ssl_cert_path = {{.SSL_CERT_PATH}}
go run main.go --template app.conf.tpl --values values.yaml --env staging --validate
Expected output:
Validating template: app.conf.tpl
Environment: staging
OK: DB_HOST = staging-db.internal (from staging)
OK: DB_PORT = 5432 (from default)
OK: DB_NAME = myapp_staging (from staging)
OK: DB_USER = staging_user (from staging)
OK: LOG_LEVEL = info (from staging)
OK: SSL_ENABLED = false (from default)
MISSING: SSL_CERT_PATH (referenced in template, not in values)
Validation FAILED: 1 missing variable(s)
The tool exits with code 1. In CI, this stops the pipeline before a bad config reaches production. This is the kind of check you add to your deployment pipeline right after terraform validate and before terraform apply.
Dry Run Mode
go run main.go --template app.conf.tpl --values values.yaml --env prod --dry-run
Expected output:
--- DRY RUN (would generate) ---
database_host = prod-db.internal
database_port = 5432
database_name = myapp_prod
database_user = prod_user
log_level = warn
ssl_enabled = true
--- END DRY RUN ---
The dry run shows exactly what the generated config would look like without writing anything to disk. You can pipe this to diff to compare against the current config:
go run main.go --template app.conf.tpl --values values.yaml --env prod --dry-run 2>/dev/null | diff app.conf - || true
This gives you a line-by-line diff before you commit to the change.
Writing to a File
go run main.go --template app.conf.tpl --values values.yaml --env prod --output app.conf
Expected output (stderr):
Generated: app.conf
The tool writes the generated config to the specified file. In a deploy script, the full workflow looks like this:
Enrich your learning with Terraform From Scratch: Provision AWS Infrastructure Step by Step
# Validate first (fails fast if something is missing)
go run main.go --template app.conf.tpl --values values.yaml --env prod --validate
# Preview the change
go run main.go --template app.conf.tpl --values values.yaml --env prod --dry-run
# Generate the config
go run main.go --template app.conf.tpl --values values.yaml --env prod --output /etc/myapp/app.conf
# Restart the service
systemctl restart myapp
What We Built
Here is a summary of each step and the trap we hit along the way:
| Step | What | Trap |
|---|---|---|
| 1 | envsubst basics | Missing variables become empty strings silently |
| 2 | envsubst for nginx and Docker Compose | No conditionals, no loops, no defaults |
| 3 | Go text/template basics | Missing keys become <no value> silently |
| 4 | Go conditionals and defaults | String "false" is truthy |
| 5 | Multi-environment config generator | YAML types cause comparison panics |
| 6 | Validation and dry run | Catch errors in CI before they reach production |
Each step solved a real problem that the previous step could not handle.
Gain comprehensive insights from Deploy Jenkins on Amazon EKS: Complete Tutorial for Pods and Deployments
Cheat Sheet
envsubst Patterns
# Substitute all environment variables
envsubst < template.tpl > output.conf
# Substitute only specific variables
envsubst '$VAR1 $VAR2' < template.tpl > output.conf
# Set and substitute in one line
export DB_HOST=localhost && envsubst < template.tpl > output.conf
# Pipe from another command
echo 'Hello ${USER}' | envsubst
Go text/template Patterns
// Always fail on missing keys
tmpl.Option("missingkey=error")
// Custom functions
funcMap := template.FuncMap{
"default": func(defVal, actual interface{}) interface{} {
if actual == nil || actual == "" {
return defVal
}
return actual
},
"toBool": func(s interface{}) bool {
str, ok := s.(string)
if !ok {
return false
}
lower := strings.ToLower(strings.TrimSpace(str))
return lower == "true" || lower == "1" || lower == "yes"
},
}
// Conditionals
// {{if eq .Env "production"}}...{{end}}
// Default values
// {{.Port | default "5432"}}
// Range over a list
// {{range .Backends}}server {{.Host}}:{{.Port}};{{end}}
// Trim whitespace
// {{- if .SSL}} (trims whitespace before the tag)
// {{end -}} (trims whitespace after the tag)
Key Rules
envsubstis the right tool for simple substitution. Do not fight its limitations. Switch to Go when you need conditionals or defaults.- Always use
missingkey=errorin Go templates. The default<no value>behavior is a production incident waiting to happen. - String
"false"is truthy in Go templates. Convert toboolbefore template execution with a customtoBoolfunction. - Merge defaults first, then environment-specific overrides. Never rely on remembering to set every variable for every environment.
- Convert all YAML values to strings before passing to templates. Mixed types cause panics in
eqcomparisons. - Validate templates in CI. A 10-second validation check saves you a 2-hour production debugging session.
Keep Reading
- Remote Server Configuration: From SSH Loops to a Go Config Tool: Push the config files you template here to remote servers with SSH and Go.
- Deployment Automation: From SSH Scripts to a Go Deploy Tool: Deploy applications that use these config templates with health checks and rollbacks.
- CI Pipeline Basics: From Shell Scripts to a Go Build Runner: Validate your templates in a CI pipeline before they reach production.
How do you manage config files across environments? Still copy-pasting, using envsubst, Helm values, or something else entirely?
Similar Articles
Related Content
More from devops
Build a log aggregator in Go from scratch. Tail files with inotify, survive log rotation, parse …
Learn Terraform with AWS from scratch. Start with a single S3 bucket, hit real errors, fix them, …
You Might Also Like
Learn nginx log analysis step by step — start with grep and awk one-liners for quick answers, then …
Contents
- Prerequisites
- Step 1: envsubst Basics – Variable Substitution
- Step 2: envsubst for Real Configs (Nginx, Docker Compose)
- Step 3: Go text/template – The Basics
- Step 4: Conditionals and Defaults in Go Templates
- Step 5: Build a Config Generator for Multiple Environments
- Step 6: Add Validation and Dry Run
- What We Built
- Cheat Sheet
- Keep Reading

