# Config Templating: From envsubst to Go

- URL: https://karandeepsingh.ca/posts/config-templating-envsubst-to-go/
- Author: Karandeep Singh
- Published: 2026-02-15
- Series: Linux Commands to Go
- Tags: golang, bash, automation
- 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.

![Laptop displaying template configuration code on a desk](https://karandeepsingh.ca/images/posts/envsubst-jinja2-templating.jpg)

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

```bash
export DB_HOST=localhost
export DB_PORT=5432
export DB_NAME=myapp_dev
```

Create a template file called `app.conf.tpl`:

```ini
database_host = ${DB_HOST}
database_port = ${DB_PORT}
database_name = ${DB_NAME}
log_level = debug
```

Run `envsubst`:

```bash
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:

```bash
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:

```bash
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`:

```nginx
server {
    listen ${NGINX_PORT};
    server_name ${SERVER_NAME};

    location / {
        proxy_pass http://${BACKEND_HOST}:${BACKEND_PORT};
    }
}
```

Generate the config and reload nginx:

```bash
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`:

```yaml
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`:

```bash
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:

```bash
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):

```go
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)
	}
}
```

```bash
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:

```diff
# render.go
@@ -12,10 +12,10 @@
 	}
 
 	data := map[string]string{
-		"DB_HOST":  "localhost",
-		"DB_PORT":  "5432",
-		"DB_NAME":  "myapp_dev",
-		"LogLevel": "debug",
+		"DB_HOST": "localhost",
+		"DB_PORT": "5432",
+		"DB_NAME": "myapp_dev",
+		// LogLevel is missing
 	}
 
 	err = tmpl.Execute(os.Stdout, data)
```

The full listing, for reference:

```go
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)
	}
}
```

```bash
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:

```diff
# render.go
@@ -6,7 +6,9 @@
 )
 
 func main() {
-	tmpl, err := template.ParseFiles("app.conf.tpl")
+	tmpl, err := template.New("app.conf.tpl").
+		Option("missingkey=error").
+		ParseFiles("app.conf.tpl")
 	if err != nil {
 		panic(err)
 	}
```

The full listing, for reference:

```go
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)
	}
}
```

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

Always use `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):

```go
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)
	}
}
```

```bash
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:

The fix is a toBool function plus one word inside the SSL conditional.

```diff
# main.go
@@ -1,7 +1,9 @@
 package main
 
 import (
+	"fmt"
 	"os"
+	"strings"
 	"text/template"
 )
 
@@ -13,12 +15,23 @@
 			}
 			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 .SSL_ENABLED}}
+{{- if toBool .SSL_ENABLED}}
 ssl_cert = /etc/ssl/certs/app.crt
 ssl_key = /etc/ssl/private/app.key
 {{- end}}`
@@ -42,4 +55,14 @@
 	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)
+	}
 }
```

The full listing, for reference:

```go
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)
	}
}
```

```bash
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:

```bash
mkdir confgen-tool && cd confgen-tool
go mod init confgen-tool
go get gopkg.in/yaml.v3
```

Create `values.yaml`:

```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):

```go
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)
	}
}
```

```bash
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}}
```

```bash
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:

```diff
# render.go
@@ -36,11 +36,12 @@
 	}
 
 	// Merge: start with defaults, then overlay environment-specific values
-	merged := make(map[string]interface{})
+	// 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] = v
+			merged[k] = fmt.Sprintf("%v", v)
 		}
 	}
 
@@ -50,7 +51,7 @@
 		os.Exit(1)
 	}
 	for k, v := range envValues {
-		merged[k] = v
+		merged[k] = fmt.Sprintf("%v", v)
 	}
 
 	// Parse and execute template
```

The full listing, for reference:

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

```bash
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:

```bash
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`:**

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

```bash
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}}
```

```bash
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

```bash
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:

```bash
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

```bash
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:

```bash
# 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.

## Cheat Sheet

### envsubst Patterns

```bash
# 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

```go
// 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

- [Remote Server Configuration: From SSH Loops to a Go Config Tool](https://karandeepsingh.ca/posts/remote-server-config-ssh-to-go/): Push the config files you template here to remote servers with SSH and Go.
- [Deployment Automation: From SSH Scripts to a Go Deploy Tool](https://karandeepsingh.ca/posts/deployment-automation-ssh-to-go/): Deploy applications that use these config templates with health checks and rollbacks.
- [CI Pipeline Basics: From Shell Scripts to a Go Build Runner](https://karandeepsingh.ca/posts/jenkins-devops-best-practices/): Validate your templates in a CI pipeline before they reach production.

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