Apps Artificial Intelligence Cloud Computing CSS Cybersecurity Data Science Database Go JavaScript Linux Python Rust Software Engineering Web Development

Reliable Application Configuration from Environment Variables in Go

4 min read .
Reliable Application Configuration from Environment Variables in Go

Environment variables are a convenient way to configure deployed Go services, but calling os.Getenv throughout an application makes configuration difficult to validate and test.

A stronger pattern is to load configuration once at startup, parse it into typed fields, validate all invariants, and pass the resulting value to the components that need it.

The examples below use only the Go standard library and work with modern supported Go releases.

Keep configuration in a typed struct

Suppose a service needs a listen address, request timeout, and optional log level:

package config

import "time"

type Config struct {
    Address        string
    RequestTimeout time.Duration
    LogLevel       string
}

Once configuration is typed, the rest of the program does not need to know whether a value came from an environment variable, a test fixture, or another source.

Distinguish missing values from empty values

os.Getenv returns an empty string both when a variable is unset and when it is explicitly set to an empty string. os.LookupEnv distinguishes those cases.

For required values:

func requiredEnv(name string) (string, error) {
    value, ok := os.LookupEnv(name)
    if !ok || strings.TrimSpace(value) == "" {
        return "", fmt.Errorf("%s is required", name)
    }
    return value, nil
}

Whether an explicit empty string should be accepted is a product decision. Make that decision in one place instead of relying on accidental behavior.

Parse values at startup

Do not pass raw strings deeper into the application. Parse durations, integers, URLs, and booleans while loading configuration.

func Load() (Config, error) {
    address, err := requiredEnv("APP_ADDRESS")
    if err != nil {
        return Config{}, err
    }

    timeoutText := envOrDefault("APP_REQUEST_TIMEOUT", "5s")
    timeout, err := time.ParseDuration(timeoutText)
    if err != nil {
        return Config{}, fmt.Errorf(
            "APP_REQUEST_TIMEOUT %q: %w",
            timeoutText,
            err,
        )
    }

    level := envOrDefault("APP_LOG_LEVEL", "info")

    cfg := Config{
        Address:        address,
        RequestTimeout: timeout,
        LogLevel:       level,
    }

    if err := cfg.Validate(); err != nil {
        return Config{}, err
    }

    return cfg, nil
}

func envOrDefault(name, fallback string) string {
    if value, ok := os.LookupEnv(name); ok {
        return value
    }
    return fallback
}

A value such as 250ms, 5s, or 2m is clearer than inventing an undocumented integer unit for a timeout.

Validate relationships between fields

Parsing proves that a value has the right syntax. Validation proves that the configuration makes sense for the application.

func (c Config) Validate() error {
    if c.RequestTimeout <= 0 {
        return errors.New("APP_REQUEST_TIMEOUT must be positive")
    }

    switch c.LogLevel {
    case "debug", "info", "warn", "error":
    default:
        return fmt.Errorf("unsupported APP_LOG_LEVEL %q", c.LogLevel)
    }

    return nil
}

For larger configurations, collect multiple validation errors when practical so an operator can fix several mistakes in one restart cycle.

Fail early instead of silently repairing bad input

Defaults are appropriate for genuinely optional settings. They are dangerous when used to hide invalid user input.

For example, if APP_REQUEST_TIMEOUT=abc, do not silently fall back to five seconds. The operator attempted to configure a value, so startup should report that it is malformed.

This distinction is useful:

  • unset optional value → apply a documented default;
  • set but invalid value → return an error;
  • missing required value → return an error.

Keep secrets out of logs

Configuration errors should identify the variable but should not echo secret values.

Avoid messages such as:

invalid DATABASE_PASSWORD="super-secret-value"

Prefer:

DATABASE_PASSWORD is required

The same rule applies to debug dumps. If a configuration struct contains credentials, tokens, private keys, or connection strings with passwords, do not print the whole struct with %+v.

Make configuration easy to test

Go tests can set environment variables for the duration of a test with t.Setenv:

func TestLoad(t *testing.T) {
    t.Setenv("APP_ADDRESS", ":8080")
    t.Setenv("APP_REQUEST_TIMEOUT", "750ms")
    t.Setenv("APP_LOG_LEVEL", "debug")

    cfg, err := Load()
    if err != nil {
        t.Fatalf("Load() error = %v", err)
    }

    if cfg.RequestTimeout != 750*time.Millisecond {
        t.Fatalf("timeout = %s", cfg.RequestTimeout)
    }
}

Use table-driven tests for malformed durations, missing required variables, unsupported enum-like values, and boundary conditions.

If tests can run in parallel, remember that environment variables are process-wide state. Avoid parallel tests that mutate the same variable names.

Pass configuration explicitly

After loading configuration in main, pass the typed values into constructors:

func main() {
    cfg, err := config.Load()
    if err != nil {
        log.Fatal(err)
    }

    server := &http.Server{
        Addr:              cfg.Address,
        ReadHeaderTimeout: cfg.RequestTimeout,
    }

    log.Fatal(server.ListenAndServe())
}

This keeps business packages independent of global environment state and makes them easier to test with ordinary values.

Common pitfalls

Reading environment variables on every request

Configuration usually changes through a new deployment or process restart. Re-reading process environment during request handling adds hidden global dependencies without providing a reliable dynamic configuration mechanism.

Using empty strings as universal defaults

An empty string may be a valid value, a missing value, or an error depending on the field. Encode that meaning explicitly.

Mixing parsing with application logic

If every package calls strconv.Atoi or time.ParseDuration, validation rules become inconsistent. Centralize loading at the application boundary.

Embedding production credentials in source code

Source examples and defaults should never contain real credentials. Use placeholders such as YOUR_API_KEY only when a value is necessary to illustrate configuration.

A useful configuration boundary

The goal is not to build a configuration framework. For many Go services, a small loader built on os.LookupEnv, the standard parsing packages, and explicit validation is enough.

Load once, fail clearly, keep values typed, and make secret handling deliberate. That gives the rest of the program a stable configuration contract instead of a collection of global strings.

Related Posts

chevron-up