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.
// 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.
// 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.
// 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.
// 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.
# 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.
# .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:
// 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:
# 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.