Skip to main content
Menu
Home WhoAmI Stack Insights Blog Contact
/user/KayD @ karandeepsingh.ca :~$ cat envsubst-jinja2-templating-guide.md

Config Templating: From envsubst to Go

Karandeep Singh
• 20 minutes read

Summary

Config templating from envsubst to Go: each step shows the shell way first, then builds it in Go. Handle missing variables, add conditionals, build a config generator for dev/staging/prod.

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
  • envsubst installed (apt install gettext-base on 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.

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

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.

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.

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:

  1. A template file (any format)
  2. A values file (YAML) with defaults and per-environment overrides
  3. 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.

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:

  1. Validation – check that all template variables have values before generating anything
  2. 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:

# 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:

StepWhatTrap
1envsubst basicsMissing variables become empty strings silently
2envsubst for nginx and Docker ComposeNo conditionals, no loops, no defaults
3Go text/template basicsMissing keys become <no value> silently
4Go conditionals and defaultsString "false" is truthy
5Multi-environment config generatorYAML types cause comparison panics
6Validation and dry runCatch errors in CI before they reach production

Each step solved a real problem that the previous step could not handle.

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

  • envsubst is the right tool for simple substitution. Do not fight its limitations. Switch to Go when you need conditionals or defaults.
  • Always use missingkey=error in Go templates. The default <no value> behavior is a production incident waiting to happen.
  • String "false" is truthy in Go templates. Convert to bool before template execution with a custom toBool function.
  • 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 eq comparisons.
  • Validate templates in CI. A 10-second validation check saves you a 2-hour production debugging session.

Keep Reading

Question

How do you manage config files across environments? Still copy-pasting, using envsubst, Helm values, or something else entirely?

Contents