How to Structure Environment Based Configuration in a Go Microservice Without Losing Your

Software Engineer Dublin

Software Engineer Dublin

Engineer Built To Scale

Beyond The Code

What They Say

Let's Build

Message Received

Privacy Policy

Terms Of Use

Cookie Policy

Disclaimer

Latest Updates

Selected Work

Showcase

What We Offer

Industries We Serve

Beyond The Screen

Built Together

View View
Nben Malla
Nben Malla

Software Engineer

Nben Malla is a software engineer based in Dublin, Ireland, specializing in microservices architecture, legacy system modernization and full stack development.

With experience across FinTech and SaaS, he has delivered scalable backend systems for global clients using Go, Java, Python, Django and Laravel, collaborating with teams across Nepal, Ireland, the Netherlands, New Zealand and the United States.

From leading legacy modernization for global banking clients to architecting microservices and distributed systems, the focus has always been to understand the problem deeply, build it right and deliver software that lasts.

  • Read Article Read Article

    Tutorials 12 mins

    How to Structure Environment Based Configuration in a Go Microservice Without Losing Your

    Nben M. 05 Jun, 2026 12 mins

    How to Structure Environment Based Configuration in a Go Microservice Without Losing Your

    Configuration handling in a Go microservice starts out looking simple. You read a few environment variables, assign them to a struct, and pass the struct to whatever needs it. By the third service on your platform, you have a slightly different struct in each one, different variable names for the same concepts, different approaches to validation, and a different answer to the question of what happens when a required variable is missing at startup.

    None of those divergences happen because engineers made bad decisions. They happen because without a shared pattern, each service makes independent decisions that are individually reasonable but collectively inconsistent. The first service uses DB_HOST and DB_PORT. The second uses DATABASE_HOST and DATABASE_PORT. The third uses DATABASE_URL. By the time you are operating six services in production, the runbooks reference six different variable names for what is functionally the same configuration value.

    The solution is a config package with a structure consistent enough to follow across every service, flexible enough to accommodate service-specific values without modification, and strict enough to fail loudly at startup when something is wrong rather than silently at runtime when it is too late.

    What the Config Package Must Do

    Before writing any code, it is worth being precise about what the package needs to handle. Most Go configuration tutorials stop at reading environment variables into a struct. Production requirements are more demanding.

    The package must validate required fields at startup and panic or exit before the server starts if any are missing. An application that starts without its database URL and then returns 500 errors on every request is harder to debug and harder to alert on than an application that refuses to start with a clear error message.

    The package must support typed values. Port numbers are integers. Timeouts are durations. Boolean feature flags are booleans. Reading everything as a string and converting later scatters the conversion logic across the codebase and duplicates the error handling for malformed values.

    The package must provide sensible defaults for non-required values. Not every environment variable needs to be set explicitly in every environment. A log level that defaults to info in production is more ergonomic than requiring every deployment to set LOG_LEVEL=info explicitly.

    The package must not depend on third-party libraries for its core functionality. Configuration is loaded before anything else starts. A dependency that fails to initialise, or that changes its API between versions, in the configuration layer means nothing in the service starts. os.Getenv and strconv from the standard library are sufficient and carry no risk.

    The Config Struct

    Every service has a config struct with two categories of fields: shared infrastructure fields that appear in every service, and service-specific fields that are unique to this service's domain.

    go
    // config/config.go
    package config
    
    import (
        "fmt"
        "os"
        "strconv"
        "time"
    )
    
    type Config struct {
        // Server
        Port            int
        Environment     string
        ServiceName     string
    
        // Database
        DatabaseURL     string
        DBMaxOpenConns  int
        DBMaxIdleConns  int
        DBConnLifetime  time.Duration
    
        // Auth
        JWTSecret       string
        JWTExpiry       time.Duration
    
        // Observability
        LogLevel        string
        SentryDSN       string
    
        // Service-specific
        PaymentGatewayURL    string
        PaymentGatewayAPIKey string
        WebhookSigningSecret string
    }
    
    func Load() (*Config, error) {
        cfg := &Config{
            Port:           getInt("PORT", 8080),
            Environment:    getString("ENVIRONMENT", "development"),
            ServiceName:    mustGetString("SERVICE_NAME"),
    
            DatabaseURL:    mustGetString("DATABASE_URL"),
            DBMaxOpenConns: getInt("DB_MAX_OPEN_CONNS", 25),
            DBMaxIdleConns: getInt("DB_MAX_IDLE_CONNS", 5),
            DBConnLifetime: getDuration("DB_CONN_LIFETIME", 5*time.Minute),
    
            JWTSecret:      mustGetString("JWT_SECRET"),
            JWTExpiry:      getDuration("JWT_EXPIRY", 24*time.Hour),
    
            LogLevel:       getString("LOG_LEVEL", "info"),
            SentryDSN:      getString("SENTRY_DSN", ""),
    
            PaymentGatewayURL:    mustGetString("PAYMENT_GATEWAY_URL"),
            PaymentGatewayAPIKey: mustGetString("PAYMENT_GATEWAY_API_KEY"),
            WebhookSigningSecret: mustGetString("WEBHOOK_SIGNING_SECRET"),
        }
    
        if err := cfg.validate(); err != nil {
            return nil, err
        }
    
        return cfg, nil
    }

    Every field is assigned exactly once at load time. The struct is immutable after Load() returns. No part of the application mutates configuration after startup, and no part of the application reads environment variables directly. Everything goes through the config struct.

    The Helper Functions

    The helpers are the repetitive part. They live in the same file and handle type conversion, defaults and error formatting consistently.

    go
    // config/config.go (continued)
    
    func mustGetString(key string) string {
        val := os.Getenv(key)
        if val == "" {
            panic(fmt.Sprintf("required environment variable %q is not set", key))
        }
        return val
    }
    
    func getString(key, fallback string) string {
        if val := os.Getenv(key); val != "" {
            return val
        }
        return fallback
    }
    
    func mustGetInt(key string) int {
        val := mustGetString(key)
        n, err := strconv.Atoi(val)
        if err != nil {
            panic(fmt.Sprintf("environment variable %q must be an integer, got %q", key, val))
        }
        return n
    }
    
    func getInt(key string, fallback int) int {
        val := os.Getenv(key)
        if val == "" {
            return fallback
        }
        n, err := strconv.Atoi(val)
        if err != nil {
            panic(fmt.Sprintf("environment variable %q must be an integer, got %q", key, val))
        }
        return n
    }
    
    func getDuration(key string, fallback time.Duration) time.Duration {
        val := os.Getenv(key)
        if val == "" {
            return fallback
        }
        d, err := time.ParseDuration(val)
        if err != nil {
            panic(fmt.Sprintf("environment variable %q must be a duration (e.g. 5m, 30s), got %q", key, val))
        }
        return d
    }
    
    func getBool(key string, fallback bool) bool {
        val := os.Getenv(key)
        if val == "" {
            return fallback
        }
        b, err := strconv.ParseBool(val)
        if err != nil {
            panic(fmt.Sprintf("environment variable %q must be a boolean, got %q", key, val))
        }
        return b
    }

    Panic on malformed values rather than returning an error. A malformed value for DB_MAX_OPEN_CONNS is a deployment misconfiguration, not a runtime condition the application can recover from. Panicking at startup surfaces the problem immediately, with a clear message, before the application accepts any traffic. An error returned from getInt that gets swallowed somewhere up the call chain surfaces as a silent zero value and a confusing bug.

    The error messages are precise. environment variable "DB_MAX_OPEN_CONNS" must be an integer, got "twenty-five" is actionable. strconv.Atoi: parsing "twenty-five": invalid syntax requires the engineer to know which variable was being parsed. Write the message you would want to read at 2am.

    Validation After Loading

    The helpers handle type conversion. A separate validate method handles business logic constraints: values that are individually valid but invalid in combination, or values that must fall within a specific range.

    go
    // config/config.go (continued)
    
    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)
        }
    
        validEnvironments := map[string]bool{
            "development": true,
            "staging":     true,
            "production":  true,
        }
        if !validEnvironments[c.Environment] {
            return fmt.Errorf("ENVIRONMENT must be one of development, staging, production, got %q", c.Environment)
        }
    
        if c.DBMaxOpenConns < c.DBMaxIdleConns {
            return fmt.Errorf(
                "DB_MAX_OPEN_CONNS (%d) must be greater than or equal to DB_MAX_IDLE_CONNS (%d)",
                c.DBMaxOpenConns, c.DBMaxIdleConns,
            )
        }
    
        if c.Environment == "production" {
            if len(c.JWTSecret) < 32 {
                return fmt.Errorf("JWT_SECRET must be at least 32 characters in production")
            }
            if c.SentryDSN == "" {
                return fmt.Errorf("SENTRY_DSN is required in production")
            }
        }
    
        return nil
    }

    Environment-conditional validation is how you enforce that production deployments meet stricter requirements than development deployments without making local development painful. A short JWT_SECRET is acceptable in development where the risk is zero. In production it is not, and the validation makes that constraint explicit and automated rather than documented in a runbook that gets missed.

    DB_MAX_OPEN_CONNS < DB_MAX_IDLE_CONNS is a constraint that pgx and database/sql do not enforce. If idle connections exceed open connections, the configuration is incoherent: you cannot have more connections sitting idle than you are allowed to have open simultaneously. Catching it at startup is better than the ambiguous behavior it produces at runtime.

    Loading in main

    config.Load() is the first call in main. Nothing else starts until it succeeds.

    go
    // cmd/api/main.go
    package main
    
    import (
        "log/slog"
        "os"
    
        "payments/config"
        "payments/internal/api"
        "payments/internal/store"
    )
    
    func main() {
        cfg, err := config.Load()
        if err != nil {
            slog.Error("configuration is invalid", "err", err)
            os.Exit(1)
        }
    
        logger := buildLogger(cfg.LogLevel, cfg.Environment)
        slog.SetDefault(logger)
    
        db, err := store.Connect(cfg.DatabaseURL, cfg.DBMaxOpenConns, cfg.DBMaxIdleConns, cfg.DBConnLifetime)
        if err != nil {
            slog.Error("database connection failed", "err", err)
            os.Exit(1)
        }
        defer db.Close()
    
        router := api.NewRouter(cfg, db, logger)
        runWithGracefulShutdown(router, cfg.Port, logger)
    }
    
    func buildLogger(level, env string) *slog.Logger {
        var logLevel slog.Level
        switch level {
        case "debug":
            logLevel = slog.LevelDebug
        case "warn":
            logLevel = slog.LevelWarn
        case "error":
            logLevel = slog.LevelError
        default:
            logLevel = slog.LevelInfo
        }
    
        if env == "production" {
            return slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: logLevel}))
        }
        return slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: logLevel}))
    }

    The logger is configured after config.Load() succeeds because the log level and format depend on config values. The os.Exit(1) on config failure means the process exits with a non-zero status code that the container orchestrator, Kubernetes or otherwise, treats as a startup failure. The pod will not enter a ready state. Health checks will not pass. The bad deployment will not receive traffic.

    buildLogger uses JSON format in production and text format in development. JSON is machine-readable for log aggregators. Text is human-readable for local development where you are watching the terminal. The distinction is handled once in main and never again.

    The .env File for Local Development

    In production, environment variables are injected by the deployment platform. In local development, an .env file loaded before the application starts provides the same variables.

    Do not write a custom .env loader. Use godotenv if you need one, or load the file in the Makefile before running the service.

    markdown
    # Makefile
    
    .env:
    	cp .env.example .env
    
    run: .env
    	export $(shell cat .env | grep -v '^#' | xargs) && go run ./cmd/api
    
    test:
    	go test ./... -race -count=1
    
    lint:
    	golangci-lint run ./...

    The run target exports all non-comment lines from .env as environment variables before starting the process. The application code does not know or care whether the variables came from a file or from the platform. The config package reads os.Getenv in both cases.

    .env.example is committed to the repository. .env is in .gitignore. Every engineer clones the repository, runs make to copy the example, fills in their local values, and runs make run.

    markdown
    # .env.example
    
    SERVICE_NAME=payments-service
    PORT=8080
    ENVIRONMENT=development
    
    DATABASE_URL=postgres://payments:password@localhost:5432/payments_dev?sslmode=disable
    DB_MAX_OPEN_CONNS=10
    DB_MAX_IDLE_CONNS=2
    DB_CONN_LIFETIME=5m
    
    JWT_SECRET=dev-secret-minimum-32-chars-long
    JWT_EXPIRY=24h
    
    LOG_LEVEL=debug
    SENTRY_DSN=
    
    PAYMENT_GATEWAY_URL=https://sandbox.paymentgateway.io
    PAYMENT_GATEWAY_API_KEY=test_key_replace_me
    WEBHOOK_SIGNING_SECRET=dev-webhook-secret

    SENTRY_DSN is present but empty in the example. This communicates that it exists and is configurable without requiring it in development. An engineer setting up a new environment does not need to hunt for why Sentry is not initialising. The variable is in the example file with a comment or an empty value, and the validation only requires it in production.

    Testing with Configuration

    Tests that need configuration values should not read from environment variables. Tests run in CI environments where the test runner controls the environment, and relying on ambient environment variables makes tests sensitive to the environment they run in.

    Provide a constructor for test configuration that supplies known values directly:

    go
    // config/testing.go
    //go:build !production
    
    package config
    
    import "time"
    
    func TestConfig() *Config {
        return &Config{
            Port:           8080,
            Environment:    "test",
            ServiceName:    "test-service",
            DatabaseURL:    "postgres://test:test@localhost:5432/test?sslmode=disable",
            DBMaxOpenConns: 5,
            DBMaxIdleConns: 2,
            DBConnLifetime: 1 * time.Minute,
            JWTSecret:      "test-secret-32-characters-minimum",
            JWTExpiry:      1 * time.Hour,
            LogLevel:       "error",
        }
    }

    The //go:build !production tag excludes this file from production builds. The function exists only in test and development builds. Tests call config.TestConfig() rather than config.Load(). The config is deterministic, does not read from the environment, and does not panic if a variable is missing in CI.

    Naming Conventions Across Services

    Consistency in variable names across services reduces the cognitive load of operating multiple services simultaneously. An engineer moving from the payments service to the notifications service should find the same variable names for the same concepts.

    The conventions we use:

    markdown
    # Server
    PORT                    integer, default 8080
    ENVIRONMENT             string: development | staging | production
    SERVICE_NAME            string, required
    
    # Database
    DATABASE_URL            string, required (full connection string)
    DB_MAX_OPEN_CONNS       integer, default 25
    DB_MAX_IDLE_CONNS       integer, default 5
    DB_CONN_LIFETIME        duration, default 5m
    DB_CONN_IDLE_TIME       duration, default 1m
    
    # Auth
    JWT_SECRET              string, required
    JWT_EXPIRY              duration, default 24h
    
    # Observability
    LOG_LEVEL               string: debug | info | warn | error, default info
    SENTRY_DSN              string, optional, required in production
    
    # Message broker (where applicable)
    BROKER_URL              string, required if service uses messaging
    BROKER_GROUP_ID         string, required if service uses messaging
    
    # External services: PREFIX_URL and PREFIX_API_KEY
    PAYMENT_GATEWAY_URL
    PAYMENT_GATEWAY_API_KEY

    DATABASE_URL as a full connection string rather than DB_HOST, DB_PORT, DB_NAME, DB_USER, DB_PASSWORD as separate variables reduces the number of variables per service by five and makes the connection string portable. Copy a DATABASE_URL and it works in any client that accepts a Postgres DSN. Copy five separate variables and you need to know how each client assembles them.

    Conclusion

    Environment-based configuration management in Go is not a hard problem. It becomes one when each service solves it independently, when validation is skipped or inconsistent, and when the naming conventions diverge enough that operating multiple services requires memorising which service uses which variable name.

    The structure described here resolves all of that with a single consistent pattern: load at startup, panic on misconfiguration, validate constraints explicitly, provide sensible defaults, and use the same variable names for the same concepts across every service.

    The return on this investment is operational. An engineer joining the platform reads one service's .env.example and understands every other service's configuration model. An alert fires at 3am, the engineer reads the startup logs, and the problem is a missing variable with a precise error message rather than a silent zero value producing ambiguous behavior six layers deep. That clarity is worth the thirty minutes it takes to write the config package correctly.