How to Implement 12-Factor App Configuration in Go

Configure Go apps for 12-Factor compliance by reading settings from environment variables using os.Getenv and os.LookupEnv.

The config problem in production

You compile a Go service on your laptop. It connects to a local database, listens on port 8080, and handles requests exactly as expected. You push the identical binary to a staging server. It crashes immediately. The database URL is missing. The port is wrong. A retry timeout defaults to zero instead of thirty seconds. The code did not change. The environment did. Configuration is the silent variable that separates a working prototype from a reliable service. The 12-factor methodology treats configuration as state that lives outside the codebase, stored in environment variables. Go makes this straightforward, but the standard library leaves the heavy lifting to you.

Configuration as state, not code

Think of environment variables like the settings panel on a thermostat. The hardware stays the same. The wiring stays the same. You only change the target temperature, the schedule, and the mode depending on the room. In software, the binary is compiled once. The configuration tells that binary which database to talk to, which port to listen on, and how aggressively to retry failed requests. The rule is simple: code travels with you. Configuration stays where it runs.

Go does not bundle configuration files into the binary by default. It expects you to read values from the process environment at startup. This design keeps deployments portable. You can run the same executable in development, staging, and production without recompiling. You only change the variables exported to the shell or container runtime.

Reading environment variables in Go

Go provides two standard functions for reading environment variables. os.Getenv returns a string and an empty string if the variable does not exist. os.LookupEnv returns a string and a boolean that tells you whether the variable was actually set. Here is the simplest way to load a required value and an optional one.

package main

import (
	"log"
	"os"
)

func main() {
	// LookupEnv returns a boolean so we can distinguish between
	// an empty string and a missing variable.
	dbURL, exists := os.LookupEnv("DATABASE_URL")
	if !exists {
		// Fail fast at startup. A missing database connection
		// means the process cannot do its job.
		log.Fatal("DATABASE_URL is required")
	}

	// Getenv returns an empty string when the key is missing.
	// We treat an empty string as a signal to use a default.
	port := os.Getenv("PORT")
	if port == "" {
		port = "8080"
	}

	log.Printf("Starting server on port %s with DB: %s", port, dbURL)
}

How the runtime handles missing values

The program starts by checking DATABASE_URL. os.LookupEnv hands back the value and a boolean. If the boolean is false, the variable was never exported to the process environment. The program calls log.Fatal, which prints the message to standard error and exits with a non-zero status. The process dies before it ever tries to open a network socket. That is the fail-fast principle in action.

Next, the program reads PORT. os.Getenv does not tell you whether the variable existed. It only gives you the string. If the string is empty, the code substitutes 8080. This pattern works because environment variables are always strings. Go will not convert them to integers or booleans for you. You have to do the conversion yourself, and you have to handle the conversion errors.

The distinction between Getenv and LookupEnv matters when you deploy to containers. Docker and Kubernetes inject environment variables into the container runtime. If a variable is omitted from the manifest, Getenv returns "". If you accidentally set the variable to an empty string in the manifest, Getenv still returns "". LookupEnv treats both cases as present, which can hide configuration mistakes. Use LookupEnv when absence means failure. Use Getenv when absence means fallback.

Structuring configuration for real applications

Real applications need more than two variables. They need structured configuration that travels through the application as a single value. Go developers typically group related settings into a struct. The struct holds typed fields, and a dedicated function loads and validates the environment variables into that struct.

package main

import (
	"fmt"
	"os"
	"strconv"
)

// Config holds all runtime settings for the service.
// Grouping them prevents global state and makes testing easier.
type Config struct {
	Port        int
	DatabaseURL string
	TimeoutSecs int
	DebugMode   bool
}

// LoadConfig reads environment variables and returns a validated Config.
// It returns an error if required values are missing or malformed.
func LoadConfig() (Config, error) {
	var cfg Config

	// LookupEnv distinguishes between missing and explicitly empty.
	dbURL, ok := os.LookupEnv("DATABASE_URL")
	if !ok {
		return cfg, fmt.Errorf("DATABASE_URL is required")
	}
	cfg.DatabaseURL = dbURL

	// Getenv returns empty string if missing, which we treat as default.
	portStr := os.Getenv("PORT")
	if portStr == "" {
		portStr = "8080"
	}
	port, err := strconv.Atoi(portStr)
	if err != nil {
		return cfg, fmt.Errorf("invalid PORT value %q: %w", portStr, err)
	}
	cfg.Port = port

	return cfg, nil
}

Notice the function signature. LoadConfig returns (Config, error). Go does not have exceptions. Errors are values you check immediately. The community accepts the if err != nil pattern because it forces you to acknowledge failure at the exact line where it happens. The function uses fmt.Errorf with the %w verb to wrap the underlying conversion error. This preserves the error chain for debugging tools while keeping the message readable.

The struct approach replaces global variables. Global configuration works for tiny scripts, but it breaks when you want to run two services in the same process or write unit tests. Passing a Config struct to your handlers and database clients keeps dependencies explicit. The receiver naming convention applies here too: if you attach methods to Config, name the receiver c, not this or self. Go style favors short, predictable names that match the type initial.

Validation and type conversion

The loader function handles the port, but a complete configuration needs validation for every field. Here is how the remaining fields get parsed and checked.

func (c *Config) validate() error {
	// Parse optional integer with a fallback.
	timeoutStr := os.Getenv("TIMEOUT_SECS")
	if timeoutStr == "" {
		c.TimeoutSecs = 30
	} else {
		timeout, err := strconv.Atoi(timeoutStr)
		if err != nil {
			return fmt.Errorf("invalid TIMEOUT_SECS %q: %w", timeoutStr, err)
		}
		c.TimeoutSecs = timeout
	}

	// Parse boolean flag. Go has no built-in bool parser for env vars.
	debugStr := os.Getenv("DEBUG")
	c.DebugMode = debugStr == "true" || debugStr == "1"

	// Enforce business rules after parsing.
	if c.TimeoutSecs < 1 || c.TimeoutSecs > 300 {
		return fmt.Errorf("TIMEOUT_SECS must be between 1 and 300, got %d", c.TimeoutSecs)
	}

	return nil
}

Configuration validation must happen at startup. Do not validate a database URL the first time a request arrives. Do not check a timeout value inside a hot loop. Load the variables, convert them, verify ranges, and reject the process if anything looks wrong. The compiler will not catch runtime configuration mistakes. You have to write the checks yourself. If you pass abc to strconv.Atoi, the function returns an error like strconv.Atoi: parsing "abc": invalid syntax. If you ignore that error and use the zero value, your server might bind to port 0, which tells the OS to pick a random available port. That usually breaks load balancers and health checks.

Another common trap is treating an empty string as a valid value when it should be missing. os.Getenv("API_KEY") returns "" if the variable is unset. If your code treats "" as a valid key, authentication fails silently. Use os.LookupEnv when the absence of a variable means the program cannot run. Use os.Getenv when a missing variable simply means you fall back to a sensible default.

Where things break

Environment variables are strings. Every conversion is a potential failure point. The standard library gives you the tools, but it does not enforce correctness. You will encounter runtime panics if you force a type assertion on an interface that holds a string, or if you pass a nil pointer to a function that expects a valid configuration. The compiler rejects programs with cannot use x (untyped int constant) as string value in argument if you mix up types during conversion. It also complains with undefined: pkg if you forget to import a package, and imported and not used if you leave dead imports in the file. Trust gofmt to keep the formatting consistent, but write the validation logic yourself.

Configuration drift is another silent killer. A variable works in development because you set it in your shell profile. It breaks in production because the CI pipeline never exported it. The fix is to document every required variable, validate them at startup, and fail loudly. The worst configuration bug is the one that silently degrades performance instead of crashing.

Picking the right approach

Use os.LookupEnv when a setting is mandatory and the process cannot start without it. Use os.Getenv when a setting is optional and you want to provide a default value. Use a typed Config struct when your application has more than three settings or when you need to pass configuration across package boundaries. Use a configuration library like viper or koanf when you need to merge environment variables with YAML files, flag parsers, and remote key-value stores. Use plain os package functions when you want zero dependencies and full control over validation logic.

Configuration is plumbing. Validate it at startup and pass it down.

Where to go next