Configuration from the Environment

Read every setting once at startup, parse it into real types, and refuse to start when something required is missing.

Syntaxvalue, ok := os.LookupEnv("APP_ADDR")

A program needs settings that differ between your laptop and the live server: which port to listen on, where the database is, how many workers to start. Writing them into the source code means editing and rebuilding the program to change one. An environment variable is a named piece of text that whatever starts your program hands to it. Your code reads it. Nobody rebuilds.

Reading one

os.LookupEnv("APP_ADDR") returns two things: the text, and a boolean saying whether the variable was set at all. os.Getenv("APP_ADDR") is the shorthand for the same read with the boolean thrown away; a missing variable comes back as an empty string. They are the same lookup.

Environment variables are always text. A port is a number and a timeout is a length of time, so convert them: strconv.Atoi for whole numbers, time.ParseDuration for text like 5s.

Once, at the start

Do all of this in one function called at the top of main, filling a Config struct. Then fail fast: collect every problem, print all of them, and exit with a non-zero status. A process that dies in one second saying DB_URL is not set is far cheaper to debug than one that serves traffic for an hour and then crashes.

What trips people up

Calling os.Getenv deep inside a function, hours into the program's life. The setting becomes invisible, untestable, and a typo in the variable name silently becomes an empty string instead of an error.

Example

Example · go
package main

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

// Config holds every setting this program reads, already parsed into real types.
type Config struct {
	Addr    string
	Workers int
}

// load reads the environment once and reports every problem it finds.
func load() (Config, []string) {
	c := Config{Workers: 4} // default for an optional setting
	var problems []string

	addr, ok := os.LookupEnv("APP_ADDR")
	if !ok {
		problems = append(problems, "APP_ADDR is not set")
	}
	c.Addr = addr

	if raw, ok := os.LookupEnv("APP_WORKERS"); ok {
		n, err := strconv.Atoi(raw) // the value is text, so convert it
		if err != nil {
			problems = append(problems, "APP_WORKERS is not a number: "+raw)
		} else {
			c.Workers = n
		}
	}
	return c, problems
}

func main() {
	// The shell or Kubernetes normally does this; done here so the program runs alone.
	if err := os.Setenv("APP_ADDR", ":8080"); err != nil {
		panic(err)
	}

	c, problems := load()
	for _, p := range problems {
		fmt.Fprintln(os.Stderr, p)
	}
	if len(problems) > 0 {
		os.Exit(1) // refuse to start rather than half-work
	}
	fmt.Printf("addr=%s workers=%d\n", c.Addr, c.Workers)

}

// Output:
// addr=:8080 workers=4

When to use it

  • Point the same container image at a staging database and then a production one without rebuilding it.
  • Make a service exit immediately with a clear message when DATABASE_URL is missing, instead of failing on the first request.
  • Turn the port and worker count into typed fields a test can construct directly.

More examples

Checking that a setting is one of a fixed set of words

Free-text settings are where bad deployments hide. A variable spelled APP_ENV=produciton would otherwise sail through and leave debug logging on in production. Reach for this whenever a setting switches behaviour rather than carrying a value: environment names, log formats, storage backends.

Example · go
// env is one of a short list, so anything else is a startup error rather
// than a surprise in production.
func loadEnv(problems []string) (string, []string) {
	env := os.Getenv("APP_ENV")
	switch env {
	case "dev", "staging", "prod":
		return env, problems
	case "":
		return "dev", problems // sensible default
	default:
		return "", append(problems, "APP_ENV must be dev, staging or prod, got "+env)
	}
}

Testing the loader with t.Setenv

Because load reads the environment in one place and returns its complaints instead of calling os.Exit itself, it can be tested like any other function. This is the practical reason to separate reading config from acting on it. Note that t.Setenv makes the test refuse to run in parallel, which is deliberate: the environment is shared by the whole process.

Example · go
func TestLoadRejectsBadWorkers(t *testing.T) {
	// t.Setenv, added in Go 1.17, sets the variable for this test only and
	// restores the old value when the test ends.
	t.Setenv("APP_ADDR", ":8080")
	t.Setenv("APP_WORKERS", "eight")

	_, problems := load()
	if len(problems) != 1 {
		t.Fatalf("want 1 problem, got %v", problems)
	}
}

Taking a secret from a file instead of a variable

Environment variables are visible to anything that can inspect the process, and they get copied into crash dumps and child processes. A file is narrower. Supporting both means the same binary works with a plain variable locally and a mounted file in a cluster, with no build flag deciding which.

Example · go
// Kubernetes can mount a Secret as a file. The _FILE convention lets one
// loader support both styles.
func loadDBPassword() (string, error) {
	if path, ok := os.LookupEnv("DB_PASSWORD_FILE"); ok {
		b, err := os.ReadFile(path)
		if err != nil {
			return "", fmt.Errorf("read DB_PASSWORD_FILE: %w", err)
		}
		// Files written by an editor or a shell usually end in a newline.
		return strings.TrimSpace(string(b)), nil
	}
	if pw, ok := os.LookupEnv("DB_PASSWORD"); ok {
		return pw, nil
	}
	return "", errors.New("set DB_PASSWORD or DB_PASSWORD_FILE")
}

Discussion

  • Be the first to comment on this lesson.