Skip to content
ExponaBLOG
GO PROGRAMMING & PRODUCTION APIS · DAY 025 / 100

Configuration in Go services: what developers should know

Environment-based config done right.

Expona Admin8 min read

Getting configuration wrong in a Go service doesn't just slow down development—it can bring your production system down. We've all experienced the scramble when an env var wasn't set, or watched a service mysteriously change behavior because someone read config deep inside a business logic function. Configuration in Go services: what developers should know is foundational knowledge that separates reliable systems from fragile ones.

Why configuration matters in production Go services

Configuration is how your service adapts to different environments. Development, staging, production—they all need different database URLs, API keys, timeouts, and feature flags. In a properly built Go service, configuration is loaded once at startup, validated immediately, and then treated as immutable throughout the application lifecycle.

The wrong approach—reading environment variables scattered throughout your codebase—creates hidden dependencies that are impossible to audit. A handler here checks an env var, a worker there checks another, and suddenly you're debugging why your service behaves differently in prod than it did in testing.

Read env vars at startup into a struct

The golden rule: load all configuration once, at application startup, into a single struct. This struct becomes your source of truth.

Here's a solid pattern we've used across multiple production services:

go
package config

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

type Config struct {
	Port              int
	DatabaseURL       string
	RedisURL          string
	LogLevel          string
	RequestTimeout    int
	MaxConnections    int
	FeatureNewUI      bool
	GracefulShutdown  int
}

func Load() (*Config, error) {
	cfg := &Config{
		Port:             getEnvInt("PORT", 8080),
		DatabaseURL:      getEnv("DATABASE_URL", ""),
		RedisURL:         getEnv("REDIS_URL", ""),
		LogLevel:         getEnv("LOG_LEVEL", "info"),
		RequestTimeout:   getEnvInt("REQUEST_TIMEOUT", 30),
		MaxConnections:   getEnvInt("MAX_CONNECTIONS", 100),
		FeatureNewUI:     getEnvBool("FEATURE_NEW_UI", false),
		GracefulShutdown: getEnvInt("GRACEFUL_SHUTDOWN_SECS", 10),
	}
	return cfg, nil
}

func getEnv(key, defaultVal string) string {
	if value, exists := os.LookupEnv(key); exists {
		return value
	}
	return defaultVal
}

func getEnvInt(key string, defaultVal int) int {
	val := getEnv(key, "")
	if val == "" {
		return defaultVal
	}
	intVal, err := strconv.Atoi(val)
	if err != nil {
		return defaultVal
	}
	return intVal
}

func getEnvBool(key string, defaultVal bool) bool {
	val := getEnv(key, "")
	if val == "" {
		return defaultVal
	}
	return val == "true" || val == "1"
}

Then in your main function, you load this once:

go
func main() {
	cfg, err := config.Load()
	if err != nil {
		log.Fatalf("failed to load config: %v", err)
	}
	
	// cfg is now available to your entire application
	// pass it to handlers, services, and workers
}

The key benefit: every line of your application that needs config values gets them from the same struct instance. No surprises, no hidden reads from the environment.

Fail fast on missing values

Not all config values are optional. Your database URL, API keys, and critical thresholds should fail immediately if they're missing, before your service even starts accepting traffic.

Here's how to enforce this:

go
package config

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

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

func Load() (*Config, error) {
	cfg := &Config{
		Port:        getEnvInt("PORT", 8080),
		DatabaseURL: getEnvRequired("DATABASE_URL"),
		APIKey:      getEnvRequired("API_KEY"),
		Environment: getEnv("ENVIRONMENT", "development"),
	}
	
	// Validate after loading
	if err := cfg.Validate(); err != nil {
		return nil, err
	}
	
	return cfg, nil
}

func getEnvRequired(key string) string {
	value, exists := os.LookupEnv(key)
	if !exists || value == "" {
		panic(fmt.Sprintf("required environment variable not set: %s", key))
	}
	return value
}

func (c *Config) Validate() error {
	if c.Port < 1 || c.Port > 65535 {
		return fmt.Errorf("invalid port: %d", c.Port)
	}
	if c.Environment != "development" && c.Environment != "staging" && c.Environment != "production" {
		return fmt.Errorf("invalid environment: %s", c.Environment)
	}
	return nil
}

This approach ensures that if you're missing critical configuration, your service exits loudly before it can enter a degraded state. In Kubernetes and modern orchestration platforms, a failed startup is far better than a service that starts but can't connect to its database.

Use envconfig or koanf

For more sophisticated applications, consider libraries that handle this pattern for you. envconfig (github.com/kelseyhightower/envconfig) and koanf (github.com/knadh/koanf) are industry standards.

Here's how envconfig simplifies things:

go
package config

import (
	"github.com/kelseyhightower/envconfig"
)

type Config struct {
	Port            int    `envconfig:"PORT" default:"8080"`
	DatabaseURL     string `envconfig:"DATABASE_URL" required:"true"`
	RedisURL        string `envconfig:"REDIS_URL" required:"true"`
	LogLevel        string `envconfig:"LOG_LEVEL" default:"info"`
	RequestTimeout  int    `envconfig:"REQUEST_TIMEOUT" default:"30"`
	FeatureNewUI    bool   `envconfig:"FEATURE_NEW_UI" default:"false"`
}

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

The required:"true" tag makes envconfig panic if the variable isn't set, giving you fail-fast behavior automatically. This reduces boilerplate and makes configuration requirements explicit in your code.

For even more control—especially if you need to support config files alongside environment variables—koanf is powerful:

go
package config

import (
	"github.com/knadh/koanf/parsers/yaml"
	"github.com/knadh/koanf/providers/env"
	"github.com/knadh/koanf/providers/file"
	"github.com/knadh/koanf/v2"
)

func Load() (*Config, error) {
	k := koanf.New(".")
	
	// Load from file first
	if err := k.Load(file.Provider("config.yaml"), yaml.Parser()); err != nil {
		// file is optional, continue
	}
	
	// Override with environment variables
	if err := k.Load(env.Provider("APP_", ".", func(s string) string {
		return strings.ToLower(strings.TrimPrefix(s, "APP_"))
	}), nil); err != nil {
		return nil, err
	}
	
	var cfg Config
	if err := k.Unmarshal("", &cfg); err != nil {
		return nil, err
	}
	return &cfg, nil
}

This gives you the best of both worlds: local development can use a yaml file, production uses env vars, and env vars take precedence. Both approaches ensure configuration is centralized and loaded once.

Never read env vars deep in code

This is where many teams slip up. A junior developer adds a quick os.Getenv() in a handler to check a feature flag, and suddenly your configuration is scattered across the codebase. Six months later, nobody knows where all the config reads are happening.

What not to do:

go
// BAD: Reading env vars inside business logic
func (h *Handler) ProcessOrder(ctx context.Context, order *Order) error {
	maxRetries := 3
	if os.Getenv("ENABLE_AGGRESSIVE_RETRY") == "true" {
		maxRetries = 10  // Where does this config live? Nobody knows.
	}
	
	for i := 0; i < maxRetries; i++ {
		// retry logic
	}
	return nil
}

What to do instead:

go
// GOOD: Config is injected
type Handler struct {
	cfg *config.Config
}

func (h *Handler) ProcessOrder(ctx context.Context, order *Order) error {
	maxRetries := 3
	if h.cfg.EnableAggressiveRetry {
		maxRetries = 10
	}
	
	for i := 0; i < maxRetries; i++ {
		// retry logic
	}
	return nil
}

By passing your config struct to services, handlers, and workers, every piece of your application knows where configuration comes from. You can audit it, test it, and change it without hunting through dozens of files.

Common mistakes

Reading env vars in init() functions: Init functions run before your main function, making them unreliable for configuration. Always load config in main and pass it along.

Defaulting every value: If something is truly optional, default it. But don't hide required values behind defaults—make them explicit and fail fast.

Type mismatches: A timeout as a string is asking for bugs. Parse to the correct type (int, bool, duration) at load time, not when you use it.

No validation: A port of 99999 or a negative timeout will compile fine. Always validate ranges and values after loading.

Config structs in domain models: Don't embed your config struct in entities or business logic types. Keep it separate and inject it where needed. This keeps your domain models clean and testable.

Key takeaways

  • Load all configuration once at startup into a single struct, then pass it around your application.
  • Fail fast: missing required values should panic or error before your service starts handling traffic.
  • Use envconfig or koanf to reduce boilerplate and make configuration requirements explicit in your code.
  • Never scatter os.Getenv() calls throughout your codebase—it makes configuration unmaintainable and hidden.
  • Validate configuration values for type, range, and constraints immediately after loading.
  • Treat your config struct as immutable after startup; it's a contract between your environment and your application.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles