Skip to content
gohacks.dev
Web

Configuration for Go Web Apps From Env Vars and Files

Load Go web app configuration from defaults, a file, environment variables and flags in a fixed order, read secrets from files and report every error at once.

10 min read
Summarize in AI
Flat illustration of settings switches, a slider and a gear.

Configuration code has a way of growing in the shape of the problems it met first. A port from an environment variable here, a file path from a flag there, a timeout hard-coded in a handler because nobody wanted to plumb it through. After a year, nobody can say what the service's settings are, which source wins when two disagree, or what happens when one is malformed. The fix is not a library. It is deciding the rules once, in one function, with tests.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

This article builds that function with the standard library: layered sources, typed values, secrets from files, validation that reports everything at once, and a safe way to log the result. It belongs to the web cluster that ends in a production HTTP server, pairs with handler dependency injection, and uses the environment access covered in environment variables. Everything ran on Go 1.25.6 with the standard library only.

The rules

Four layers, applied in this order, each overriding the one before:

  1. Defaults in code. The service runs with sensible values and no input at all, which is also what makes local development painless.
  2. A config file, if one is named. Good for settings that are long or structured, such as a list of allowed origins.
  3. Environment variables. The deployment's layer: containers, systemd units and Kubernetes manifests set these.
  4. Command-line flags. The operator's override, handy for a one-off run.

The order matches how specific the source is. A flag typed for this run beats an environment variable set for this deployment, which beats a file shared across deployments, which beats a default. Whatever order you choose, write it down in the --help text and test it, because "which one wins" is the first question anyone asks when a setting misbehaves.

One function, with its inputs injected

The loader below takes getenv and readFile as parameters instead of calling os.Getenv and os.ReadFile. That one decision makes it testable without touching the process environment or the disk.

config.goGo
package main

import (
	"encoding/json"
	"errors"
	"flag"
	"fmt"
	"io"
	"net"
	"strconv"
	"strings"
	"time"
)

type Config struct {
	Addr              string
	ReadHeaderTimeout time.Duration
	ShutdownTimeout   time.Duration
	LogLevel          string
	AllowedOrigins    []string
	Debug             bool
	DatabaseURL       string // secret
}

func defaults() Config {
	return Config{
		Addr:              ":8080",
		ReadHeaderTimeout: 5 * time.Second,
		ShutdownTimeout:   20 * time.Second,
		LogLevel:          "info",
	}
}

// fileConfig mirrors the JSON file. Pointers tell "absent" apart from "set to the zero value".
type fileConfig struct {
	Addr              *string  `json:"addr"`
	ReadHeaderTimeout *string  `json:"read_header_timeout"`
	LogLevel          *string  `json:"log_level"`
	AllowedOrigins    []string `json:"allowed_origins"`
	Debug             *bool    `json:"debug"`
}

// Load builds the configuration: defaults < file < environment < flags. It returns every problem it finds.
func Load(args []string, getenv func(string) string, readFile func(string) ([]byte, error)) (Config, error) {
	cfg := defaults()
	var errs []error
	bad := func(format string, a ...any) { errs = append(errs, fmt.Errorf(format, a...)) }

	// Flags are parsed first only to learn the config file path; their values are applied last.
	fs := flag.NewFlagSet("app", flag.ContinueOnError)
	fs.SetOutput(io.Discard)
	fFile := fs.String("config", getenv("APP_CONFIG"), "path to a JSON config file")
	fAddr := fs.String("addr", "", "listen address")
	fLevel := fs.String("log-level", "", "debug, info, warn or error")
	fDebug := fs.Bool("debug", false, "enable debug behavior")
	if err := fs.Parse(args); err != nil {
		return cfg, fmt.Errorf("flags: %w", err)
	}

	// Layer 2: the file.
	if *fFile != "" {
		raw, err := readFile(*fFile)
		var fc fileConfig
		switch {
		case err != nil:
			bad("config file %s: %v", *fFile, err)
		case json.Unmarshal(raw, &fc) != nil:
			bad("config file %s: not valid JSON", *fFile)
		default:
			if fc.Addr != nil {
				cfg.Addr = *fc.Addr
			}
			if fc.ReadHeaderTimeout != nil {
				if d, err := time.ParseDuration(*fc.ReadHeaderTimeout); err != nil {
					bad("config file read_header_timeout: %v", err)
				} else {
					cfg.ReadHeaderTimeout = d
				}
			}
			if fc.LogLevel != nil {
				cfg.LogLevel = *fc.LogLevel
			}
			if fc.AllowedOrigins != nil {
				cfg.AllowedOrigins = fc.AllowedOrigins
			}
			if fc.Debug != nil {
				cfg.Debug = *fc.Debug
			}
		}
	}

	// Layer 3: the environment.
	if v := getenv("APP_ADDR"); v != "" {
		cfg.Addr = v
	}
	if v := getenv("APP_LOG_LEVEL"); v != "" {
		cfg.LogLevel = v
	}
	if v := getenv("APP_READ_HEADER_TIMEOUT"); v != "" {
		if d, err := time.ParseDuration(v); err != nil {
			bad("APP_READ_HEADER_TIMEOUT: %v", err)
		} else {
			cfg.ReadHeaderTimeout = d
		}
	}
	if v := getenv("APP_SHUTDOWN_TIMEOUT"); v != "" {
		if d, err := time.ParseDuration(v); err != nil {
			bad("APP_SHUTDOWN_TIMEOUT: %v", err)
		} else {
			cfg.ShutdownTimeout = d
		}
	}
	if v := getenv("APP_ALLOWED_ORIGINS"); v != "" {
		cfg.AllowedOrigins = strings.Split(v, ",")
	}
	if v := getenv("APP_DEBUG"); v != "" {
		if b, err := strconv.ParseBool(v); err != nil {
			bad("APP_DEBUG: %v", err)
		} else {
			cfg.Debug = b
		}
	}

	// Secrets: DATABASE_URL directly, or DATABASE_URL_FILE pointing at a mounted secret.
	switch path, direct := getenv("DATABASE_URL_FILE"), getenv("DATABASE_URL"); {
	case path != "":
		raw, err := readFile(path)
		if err != nil {
			bad("DATABASE_URL_FILE: %v", err)
		}
		cfg.DatabaseURL = strings.TrimSpace(string(raw))
	case direct != "":
		cfg.DatabaseURL = direct
	}

	// Layer 4: flags the operator set explicitly.
	fs.Visit(func(f *flag.Flag) {
		switch f.Name {
		case "addr":
			cfg.Addr = *fAddr
		case "log-level":
			cfg.LogLevel = *fLevel
		case "debug":
			cfg.Debug = *fDebug
		}
	})

	errs = append(errs, cfg.validate()...)
	return cfg, errors.Join(errs...)
}

func (c Config) validate() []error {
	var errs []error
	if _, _, err := net.SplitHostPort(c.Addr); err != nil {
		errs = append(errs, fmt.Errorf("addr %q is not host:port", c.Addr))
	}
	if c.ReadHeaderTimeout <= 0 {
		errs = append(errs, fmt.Errorf("read header timeout must be positive, got %v", c.ReadHeaderTimeout))
	}
	if c.ShutdownTimeout < time.Second {
		errs = append(errs, fmt.Errorf("shutdown timeout %v is too short to drain requests", c.ShutdownTimeout))
	}
	switch c.LogLevel {
	case "debug", "info", "warn", "error":
	default:
		errs = append(errs, fmt.Errorf("log level %q is not one of debug, info, warn, error", c.LogLevel))
	}
	return errs
}

// String is what gets logged at startup. The secret never appears.
func (c Config) String() string {
	secret := "unset"
	if c.DatabaseURL != "" {
		secret = "set (" + strconv.Itoa(len(c.DatabaseURL)) + " bytes)"
	}
	return fmt.Sprintf("addr=%s read_header_timeout=%v shutdown_timeout=%v log_level=%s debug=%v origins=%v database_url=%s",
		c.Addr, c.ReadHeaderTimeout, c.ShutdownTimeout, c.LogLevel, c.Debug, c.AllowedOrigins, secret)
}

The shape worth noticing is fileConfig. It uses pointers so that a file saying "debug": false is different from a file that does not mention debug. With plain values the loader could not tell, and a file would overwrite a default with a zero value. Everything else follows the layer order, one block per source, with problems recorded through bad instead of returned on the spot.

Running it

The program feeds the loader a fake environment and a fake file system, so each scenario is a few lines and nothing touches the real machine.

main.goGo
package main

import (
	"errors"
	"fmt"
	"os"
	"strings"
)

func main() {
	files := map[string]string{
		"/etc/app/config.json": `{"addr":":9000","read_header_timeout":"3s","allowed_origins":["https://a.example","https://b.example"],"debug":true}`,
		"/run/secrets/db":      "postgres://app:[email protected]/app\n",
	}
	readFile := func(p string) ([]byte, error) {
		if s, ok := files[p]; ok {
			return []byte(s), nil
		}
		return nil, os.ErrNotExist
	}
	env := func(m map[string]string) func(string) string { return func(k string) string { return m[k] } }

	run := func(label string, args []string, e map[string]string) {
		cfg, err := Load(args, env(e), readFile)
		fmt.Println(label)
		if err != nil {
			for _, line := range strings.Split(strings.TrimSpace(err.Error()), "\n") {
				fmt.Println("   error:", line)
			}
			return
		}
		fmt.Println("  ", cfg)
	}

	run("1. defaults only", nil, nil)
	run("2. file", []string{"-config", "/etc/app/config.json"}, nil)
	run("3. file, then environment overrides", []string{"-config", "/etc/app/config.json"},
		map[string]string{"APP_ADDR": ":7000", "APP_READ_HEADER_TIMEOUT": "10s", "APP_DEBUG": "false"})
	run("4. environment, then a flag overrides", []string{"-addr", ":6000"},
		map[string]string{"APP_ADDR": ":7000", "APP_LOG_LEVEL": "warn"})
	run("5. secret from a mounted file", nil, map[string]string{"DATABASE_URL_FILE": "/run/secrets/db"})
	run("6. several mistakes reported together", []string{"-addr", "8080"},
		map[string]string{"APP_READ_HEADER_TIMEOUT": "soon", "APP_LOG_LEVEL": "verbose", "APP_SHUTDOWN_TIMEOUT": "100ms", "DATABASE_URL_FILE": "/run/secrets/missing"})
	_, err := Load([]string{"-nope"}, env(nil), readFile)
	fmt.Println("7. unknown flag:", errors.Unwrap(err) != nil)
}
Text
1. defaults only
   addr=:8080 read_header_timeout=5s shutdown_timeout=20s log_level=info debug=false origins=[] database_url=unset
2. file
   addr=:9000 read_header_timeout=3s shutdown_timeout=20s log_level=info debug=true origins=[https://a.example https://b.example] database_url=unset
3. file, then environment overrides
   addr=:7000 read_header_timeout=10s shutdown_timeout=20s log_level=info debug=false origins=[https://a.example https://b.example] database_url=unset
4. environment, then a flag overrides
   addr=:6000 read_header_timeout=5s shutdown_timeout=20s log_level=warn debug=false origins=[] database_url=unset
5. secret from a mounted file
   addr=:8080 read_header_timeout=5s shutdown_timeout=20s log_level=info debug=false origins=[] database_url=set (37 bytes)
6. several mistakes reported together
   error: APP_READ_HEADER_TIMEOUT: time: invalid duration "soon"
   error: DATABASE_URL_FILE: file does not exist
   error: addr "8080" is not host:port
   error: shutdown timeout 100ms is too short to drain requests
   error: log level "verbose" is not one of debug, info, warn, error
7. unknown flag: true

Scenario two shows the file's values replacing the defaults, including the list of origins. In three the environment beats the file for the address and the timeout, and APP_DEBUG=false overrides the file's true: the explicit false is honored because the environment layer checks "is it set" and not "is it non-zero". In four a flag beats the environment, but the environment's log level still applies, since no flag touched it, which is the reason flags are applied through fs.Visit (only the flags the operator actually passed) and not by comparing against zero values.

Five loads the database URL from a mounted file, the pattern Docker secrets and Kubernetes secret volumes use. The log line shows database_url=set (...) and a byte count, never the string. Six is the scenario that justifies errors.Join: five separate mistakes (a malformed address, an unparseable duration, an unknown log level, a too-short shutdown timeout, a missing secret file) reported in one run. A service that stopped at the first error would make the operator fix them one deploy at a time.

Decisions behind the code

Why not read the environment everywhere? A call to os.Getenv deep in a handler is a hidden input: the function's behavior depends on something its signature does not show, and a test has to mutate process state to change it. t.Setenv helps, but it prevents parallel tests. Load once, pass values down.

Why prefix variables? APP_ADDR, not ADDR or PORT. Generic names collide with whatever else is in the container's environment. The exception is a name a platform defines for you; some hosting platforms provide PORT, and reading it as a documented fallback is reasonable.

Why *_FILE for secrets? Environment variables are visible to every child process, show up in /proc/PID/environ for the same user, and are often dumped by crash reporters and debug endpoints. A file with restrictive permissions, mounted by the orchestrator, is a smaller exposure. It is not a vault. For rotating secrets you want a loader that re-reads the file, which is more than a startup function.

Why fail at startup? A timeout of 0 that is accepted silently becomes "no timeout" and shows up three weeks later as a hung connection. Validation at startup turns it into a refusal to boot with a clear message, which a deployment pipeline catches before traffic arrives. Reject values that are merely strange as well: ReadHeaderTimeout of an hour parses as a valid duration and is almost certainly a typo for a minute.

Testing the loader

Because the loader takes its inputs as arguments, its tests are a table: each row is a set of args, an environment map and a file map, and the expected Config or the expected error text. Write a row for every precedence rule (the file beats the default, the environment beats the file, a flag beats the environment) and one for each validation. When someone adds a setting, the same row shape tells them where to add the test. Check also that String() never contains the secret by asserting !strings.Contains(cfg.String(), "s3cr3t"); it is the kind of regression that a later refactor, adding a field to the log line, introduces without anyone noticing.

When a library is the right answer

os.Getenv and flag stop being enough when you have dozens of settings across many services, need nested structures or hot reload. Then a library is worth its cost: kelseyhightower/envconfig and caarlos0/env map environment variables to struct tags, and knadh/koanf and spf13/viper merge files, environment and flags with a similar precedence. I have not run them for this article, so I will not recommend one beyond saying the same discipline applies to all: one struct, loaded once, validated, with secrets kept out of logs. The twelve-factor app's config chapter is the origin of the environment-first convention, and the Go standard library documents flag and errors.Join for the pieces used above.

Also inDevOps
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.