Environment variables and configuration in Go for backend applications

How to read environment variables in Go, configure per environment, validate and separate secrets. Explicit configuration with no magic.

Cover for Environment variables and configuration in Go for backend applications

Configuration in Go is boring. And that is exactly what you want.

No magic auto-configuration. No framework scanning annotations to inject values. No application.yml with profiles resolved in cascade based on the environment, the lunar phase, and the CI server’s mood. In Go, you read an environment variable, assign it to a field, and get on with your life. If it is missing, the program fails at startup, not at three in the morning when a user makes the request that hits that code for the first time.

That simplicity has a cost: you have to wire the plumbing yourself. But that cost is paid once, at the start of the project, and then you forget about it. What follows is a pattern I use in all my Go services that covers 95% of cases without adding unnecessary complexity.


os.Getenv and os.LookupEnv: the basics

Go has two functions in the standard library for reading environment variables. The difference between them is subtle but important.

os.Getenv returns the value of the variable or an empty string if it does not exist:

package main

import (
    "fmt"
    "os"
)

func main() {
    port := os.Getenv("PORT")
    fmt.Println("Port:", port) // "" if PORT is not defined
}

The problem is that you cannot distinguish between “the variable exists and its value is empty” and “the variable does not exist”. In most cases it does not matter, but when you need to know if someone explicitly configured something, you need os.LookupEnv:

func main() {
    port, exists := os.LookupEnv("PORT")
    if !exists {
        port = "8080"
    }
    fmt.Println("Port:", port)
}

os.LookupEnv returns the value and a boolean indicating whether the variable exists. This lets you implement default values explicitly: if the variable is not set, you use the default. If it is set but empty, you respect that empty value.

For simple configurations (a script, a small CLI tool), these two functions are all you need. Nothing more. But as soon as your service has more than five or six configuration variables, reading them one by one in main() becomes a repetitive and hard-to-maintain block of code.


The Config struct pattern: centralized configuration

The natural step is to group all configuration in a struct and create a function that builds it from the environment. This pattern is so common in Go that it is practically an unwritten standard.

// internal/config/config.go
package config

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

type Config struct {
    Port        int
    DatabaseURL string
    LogLevel    string
    Environment string
}

func Load() (*Config, error) {
    port, err := getEnvInt("PORT", 8080)
    if err != nil {
        return nil, fmt.Errorf("invalid PORT: %w", err)
    }

    dbURL, err := getEnvRequired("DATABASE_URL")
    if err != nil {
        return nil, err
    }

    return &Config{
        Port:        port,
        DatabaseURL: dbURL,
        LogLevel:    getEnv("LOG_LEVEL", "info"),
        Environment: getEnv("ENVIRONMENT", "development"),
    }, nil
}

func getEnv(key, fallback string) string {
    if val, ok := os.LookupEnv(key); ok {
        return val
    }
    return fallback
}

func getEnvRequired(key string) (string, error) {
    val, ok := os.LookupEnv(key)
    if !ok || val == "" {
        return "", fmt.Errorf("required environment variable %s is not set", key)
    }
    return val, nil
}

func getEnvInt(key string, fallback int) (int, error) {
    val, ok := os.LookupEnv(key)
    if !ok {
        return fallback, nil
    }
    parsed, err := strconv.Atoi(val)
    if err != nil {
        return 0, fmt.Errorf("cannot parse %s=%q as int: %w", key, val, err)
    }
    return parsed, nil
}

And in your main.go:

func main() {
    cfg, err := config.Load()
    if err != nil {
        log.Fatalf("configuration error: %v", err)
    }

    // cfg is passed as a dependency to handlers, services, etc.
    log.Printf("Starting server on :%d [%s]", cfg.Port, cfg.Environment)
}

Notice several things:

  1. Load() returns an error. It does not call log.Fatal internally. The decision of what to do with a configuration error is made by main(), not the config package.
  2. Required variables fail explicitly. If DATABASE_URL is missing, the service does not start. It does not keep running with an empty string waiting for something to explode.
  3. Default values are visible. They are not in a separate YAML file or a constant in another package. They are right there, next to the read, where you can see them.
  4. The type is correct. Port is an int, not a string. The conversion happens once, when the config is loaded. The rest of the code works with native types.

This pattern is the one I use as a base. For a service with 10-15 configuration variables, it works perfectly without external dependencies. If you are interested in how this integrates into the overall project structure, check out the article on project structure.


Default values and validation

Default values should follow a simple principle: the default makes it work locally without configuring anything. The port is 8080, the log level is debug, the environment is development. If a developer clones the repo and runs go run ., it should start without having to create files or export variables.

But not everything can have a default. The database URL should not have a default value because that means someone could start the service and accidentally connect to a database that is not theirs. Secrets either. Critical variables must be required, and the service must fail loudly if they are missing.

For more complex validations, add a Validate() method to the struct:

func (c *Config) Validate() error {
    if c.Port < 1 || c.Port > 65535 {
        return fmt.Errorf("PORT must be between 1 and 65535, got %d", c.Port)
    }

    validEnvs := map[string]bool{
        "development": true,
        "staging":     true,
        "production":  true,
    }
    if !validEnvs[c.Environment] {
        return fmt.Errorf("ENVIRONMENT must be one of [development, staging, production], got %q", c.Environment)
    }

    if c.Environment == "production" && c.LogLevel == "debug" {
        return fmt.Errorf("LOG_LEVEL=debug is not allowed in production")
    }

    return nil
}

And in Load():

func Load() (*Config, error) {
    cfg := &Config{
        // ... load values ...
    }

    if err := cfg.Validate(); err != nil {
        return nil, fmt.Errorf("config validation failed: %w", err)
    }

    return cfg, nil
}

Validation happens at load time, not when someone uses the value. If PORT is 99999, the program never even tries to call ListenAndServe. It fails at startup, with a clear message of what is wrong and what was expected.


godotenv for local development

In production, environment variables are injected by the orchestrator (Docker, Kubernetes, your cloud platform). Locally, manually exporting variables every time you open a terminal is tedious. That is what godotenv is for.

go get github.com/joho/godotenv

Create a .env file at the root of the project:

PORT=3000
DATABASE_URL=postgres://postgres:postgres@localhost:5432/myapp?sslmode=disable
LOG_LEVEL=debug
ENVIRONMENT=development

And load the file before reading the configuration:

package main

import (
    "log"

    "github.com/joho/godotenv"
    "my-project/internal/config"
)

func main() {
    // Locally, load .env. In production, the file does not exist and nothing happens.
    _ = godotenv.Load()

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

    log.Printf("Starting server on :%d", cfg.Port)
}

Important details:

  • Ignore the error from godotenv.Load(). If the file does not exist (as in production), it simply does nothing. You do not want your service to fail in production because a .env is missing.
  • .env does not override existing variables. If a variable is already defined in the environment, godotenv.Load() respects it. This is correct: the system environment takes priority.
  • .env goes in .gitignore. Always. Without exceptions. The .env file contains local database URLs, development tokens, and developer-specific configuration. It does not belong in the repository.

If you want an example file that does go in the repo, create a .env.example with dummy values:

PORT=8080
DATABASE_URL=postgres://user:password@localhost:5432/dbname?sslmode=disable
LOG_LEVEL=info
ENVIRONMENT=development

This documents what variables the project needs without exposing real values.


envconfig and cleanenv: struct-based configuration

When your service has 20 or more configuration variables, the helper functions getEnv, getEnvInt, getEnvRequired become repetitive. That is the moment to use a library that maps environment variables directly to a struct using tags.

kelseyhightower/envconfig

The classic library. Simple, mature, with few dependencies:

go get github.com/kelseyhightower/envconfig
package config

import (
    "time"

    "github.com/kelseyhightower/envconfig"
)

type Config struct {
    Port            int           `envconfig:"PORT" default:"8080"`
    DatabaseURL     string        `envconfig:"DATABASE_URL" required:"true"`
    LogLevel        string        `envconfig:"LOG_LEVEL" default:"info"`
    Environment     string        `envconfig:"ENVIRONMENT" default:"development"`
    ReadTimeout     time.Duration `envconfig:"READ_TIMEOUT" default:"5s"`
    WriteTimeout    time.Duration `envconfig:"WRITE_TIMEOUT" default:"10s"`
    MaxConnections  int           `envconfig:"MAX_CONNECTIONS" default:"25"`
    CacheEnabled    bool          `envconfig:"CACHE_ENABLED" default:"true"`
}

func Load() (*Config, error) {
    var cfg Config
    if err := envconfig.Process("", &cfg); err != nil {
        return nil, err
    }
    return &cfg, nil
}

The first argument of Process is a prefix. If you pass "APP", it looks for APP_PORT, APP_DATABASE_URL, etc. Passing an empty string looks for variables without a prefix. The prefix is useful when running several services in the same environment and you want to avoid name collisions.

envconfig supports types natively: int, bool, float64, time.Duration, []string (comma-separated), and types that implement encoding.TextUnmarshaler. That covers practically everything.

ilyakaznacheev/cleanenv

A more modern alternative with support for multiple sources (YAML, TOML, ENV):

go get github.com/ilyakaznacheev/cleanenv
package config

import "github.com/ilyakaznacheev/cleanenv"

type Config struct {
    Port        int    `env:"PORT" env-default:"8080"`
    DatabaseURL string `env:"DATABASE_URL" env-required:"true"`
    LogLevel    string `env:"LOG_LEVEL" env-default:"info"`
    Environment string `env:"ENVIRONMENT" env-default:"development"`
}

func Load() (*Config, error) {
    var cfg Config
    if err := cleanenv.ReadEnv(&cfg); err != nil {
        return nil, err
    }
    return &cfg, nil
}

cleanenv can also read from a configuration file and combine values with environment variables, which can be useful if you have complex defaults that do not fit in a tag.

My recommendation: if you only read environment variables, envconfig is sufficient and lighter. If you need to combine configuration files with environment variables, cleanenv is worth it. If you have fewer than 10 variables, manual helper functions are perfectly valid and you save yourself a dependency.


Separating secrets from configuration

This is a mistake I see constantly: treating secrets the same as the rest of the configuration. The database URL, API keys, JWT tokens… all mixed into the same environment variables, with the same access level, in the same .env.

Locally, this works. In production, it is a security problem. Secrets need different treatment:

  1. Rotation. An API key should be rotatable without redeploying. A standard environment variable requires restarting the process.
  2. Auditing. You need to know who accessed a secret and when. Environment variables leave no trail.
  3. Access control. Not all developers should be able to see production secrets. If they are in a shared .env or in CI pipeline variables, anyone with repository access can see them.

The solution is not complicated. Separate secrets from the rest of the configuration at the code level:

type Config struct {
    Port        int
    LogLevel    string
    Environment string
}

type Secrets struct {
    DatabaseURL    string
    JWTSecret      string
    StripeAPIKey   string
}

Locally, both can come from the .env. But in production, Config comes from regular environment variables and Secrets comes from a secrets manager: AWS Secrets Manager, HashiCorp Vault, GCP Secret Manager, Azure Key Vault, or Kubernetes secrets.

func LoadSecrets(env string) (*Secrets, error) {
    if env == "development" {
        return &Secrets{
            DatabaseURL:  os.Getenv("DATABASE_URL"),
            JWTSecret:    os.Getenv("JWT_SECRET"),
            StripeAPIKey: os.Getenv("STRIPE_API_KEY"),
        }, nil
    }

    // In production, read from a secrets manager
    return loadFromSecretsManager()
}

This separation forces you to think about what is a secret and what is not. If PORT=8080 leaks, nothing bad happens. If DATABASE_URL=postgres://admin:supersecret@prod-db:5432/app leaks, you have a problem. Treating them differently in code reflects that they are different in reality.


Configuration per environment: dev, staging, production

Per-environment configuration should not be solved with different configuration files for each environment (config.dev.yaml, config.staging.yaml, config.prod.yaml). That pattern is fragile: the files get out of sync, someone adds a variable to the development file and forgets to add it to production, and the error only appears at deploy time.

The idiomatic way in Go (and in Twelve-Factor Apps in general) is that configuration comes from the environment, and the environment is injected by whoever deploys:

  • Locally: .env with godotenv.
  • Docker: variables in docker-compose.yml or --env-file.
  • Kubernetes: ConfigMaps and Secrets mounted as environment variables.
  • Cloud: service environment variables (ECS task definitions, Cloud Run env, etc.).
# docker-compose.yml
services:
  api:
    build: .
    environment:
      - PORT=8080
      - DATABASE_URL=postgres://postgres:postgres@db:5432/myapp?sslmode=disable
      - LOG_LEVEL=debug
      - ENVIRONMENT=development
    ports:
      - "8080:8080"

If you want to see how this integrates into a complete Docker container, I cover it in the article on dockerizing a Go API.

The key point: your Go code does not know or care where the variables come from. It just calls os.Getenv or uses the configuration struct. The injection mechanism is the responsibility of the runtime environment, not the application.

If you need different behavior per environment (for example, disabling rate limiting in development or using a structured logger only in production), make it explicit:

func setupLogger(cfg *config.Config) *slog.Logger {
    if cfg.Environment == "production" {
        return slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
            Level: parseLogLevel(cfg.LogLevel),
        }))
    }
    return slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
        Level: slog.LevelDebug,
    }))
}

The environment determines the behavior, but the logic is transparent. No hidden profiles, no automatic activations. If you want to know what happens in production, you read the if and you know.


Testing with different configurations

Testing code that depends on configuration is straightforward if you have followed the struct pattern. Instead of modifying environment variables in tests (which is fragile and causes race conditions if you run tests in parallel), you pass the struct directly with the values you want:

func TestServiceWithCustomConfig(t *testing.T) {
    cfg := &config.Config{
        Port:        9090,
        LogLevel:    "debug",
        Environment: "test",
    }

    svc := service.New(cfg)
    // ... assertions ...
}

If any test needs to modify environment variables (for example, to test the Load() function itself), use t.Setenv, available since Go 1.17:

func TestLoadConfig(t *testing.T) {
    t.Setenv("PORT", "3000")
    t.Setenv("DATABASE_URL", "postgres://localhost:5432/testdb")

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

    if cfg.Port != 3000 {
        t.Errorf("expected port 3000, got %d", cfg.Port)
    }
}

t.Setenv is important because it restores the original value of the variable when the test ends. It does not contaminate the environment for other tests. And if you try to use it in a test running with t.Parallel(), Go panics at compile time. This is correct: modifying global environment variables from parallel tests is a recipe for flaky tests.

To test validation:

func TestLoadConfigMissingRequired(t *testing.T) {
    // We do not set DATABASE_URL
    t.Setenv("PORT", "8080")

    _, err := config.Load()
    if err == nil {
        t.Fatal("expected error for missing DATABASE_URL, got nil")
    }
}

func TestLoadConfigInvalidPort(t *testing.T) {
    t.Setenv("PORT", "not-a-number")
    t.Setenv("DATABASE_URL", "postgres://localhost/testdb")

    _, err := config.Load()
    if err == nil {
        t.Fatal("expected error for invalid PORT, got nil")
    }
}

These tests are fast, need no external infrastructure, and validate that your configuration fails in the right way. To learn more about testing patterns in Go, check the article on REST API with Go, which includes integration tests with injected configuration.


Common mistakes: hardcoded values, missing validation

These are the configuration mistakes I have seen most often in Go projects in production.

Hardcoding values that should be configurable

// Bad
db, err := sql.Open("postgres", "postgres://localhost:5432/mydb")

// Good
db, err := sql.Open("postgres", cfg.DatabaseURL)

If there is a string literal that changes between environments, it should be a configuration variable. Full stop. The usual candidates: URLs to external services, timeouts, pool sizes, feature flags, API keys (which should also be secrets).

Not validating at startup

// Bad: fails at 3 AM when someone makes a request
func (h *Handler) CreateOrder(w http.ResponseWriter, r *http.Request) {
    apiKey := os.Getenv("STRIPE_API_KEY") // may be empty
    client := stripe.NewClient(apiKey)     // fails here, silently or not
    // ...
}

// Good: fails at startup
func main() {
    cfg, err := config.Load() // validates that STRIPE_API_KEY exists
    if err != nil {
        log.Fatalf("config: %v", err)
    }
    stripeClient := stripe.NewClient(cfg.StripeAPIKey)
    handler := NewHandler(stripeClient)
}

All configuration is read and validated once, at startup. If something is missing, the process does not even start. A clear error in the deployment log is better than a cryptic error at three in the morning.

Reading environment variables outside the configuration layer

// Bad: os.Getenv scattered throughout the code
func (s *Service) DoSomething() {
    if os.Getenv("ENVIRONMENT") == "production" {
        // ...
    }
}

// Good: configuration injected as a dependency
func (s *Service) DoSomething() {
    if s.cfg.Environment == "production" {
        // ...
    }
}

If os.Getenv appears outside the config package, it is a red flag. It means configuration is scattered and you cannot know what variables your application needs without searching through all the code. Centralize the reading in a single place.

Not having a .env.example

If a new developer clones the project and does not know what variables it needs, they will lose time. A .env.example with all variables documented (and with dummy values for secrets) is the most useful documentation you can have. It stays up to date because it fails if it is not.


Configuration should be the most boring part of your service

After having set up configuration in quite a few Go services, the pattern that works best is always the same: a struct that defines everything the service needs, loads from environment variables at startup, validates before continuing, and injects as a dependency to the rest of the code. Secrets separated from ordinary configuration, .env only locally with godotenv, and the runtime environment (Docker, Kubernetes, cloud) injecting values in production.

No magic, no auto-configuration, no hidden profiles. You read from the environment, validate, and start up. If something is missing, the service does not start and tells you clearly. That is all.

If managing your service’s configuration feels exciting, you are probably overcomplicating it. Configuration should be invisible: it works or fails fast at startup. Nothing more.

OshyTech

Backend and data engineering focused on scalable systems, automation, and AI.

Navigation

Copyright 2026 OshyTech. All Rights Reserved