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

Validating environment at startup: a practical Go guide

Fail fast when config is wrong.

Expona Admin11 min read

Introduction

Configuration errors in production often surface minutes after deployment, when your service crashes because a database URL was missing or a port number was malformed. Validating environment at startup: a practical Go guide means catching these problems before your application accepts its first request, not after. This article walks through a battle-tested pattern for parsing config into a struct in main, checking required values, logging config without secrets, and exiting with a clear message.

Why startup validation matters

When configuration is wrong, you want to know immediately. A service that starts successfully but fails unpredictably under load wastes debugging time and erodes confidence in your deployment pipeline. By validating environment variables and configuration files at startup, you shift the problem from runtime surprises to immediate, obvious failures that your CI/CD pipeline and local development catch right away.

We have seen teams lose hours to subtle config bugs—a typo in a Redis host, a missing API key, an invalid timeout value—that only appeared after the service joined a load balancer. Startup validation eliminates that class of problem entirely.

Parse config into a struct in main

The cleanest approach is to define a configuration struct and populate it early in your main function. This gives you type safety, makes dependencies explicit, and centralizes all configuration logic in one place.

go
package main

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

type Config struct {
	DBHost     string
	DBPort     int
	DBUser     string
	DBPassword string
	APIKey     string
	LogLevel   string
	Port       int
	Environment string
}

func main() {
	cfg, err := loadConfig()
	if err != nil {
		log.Fatalf("Failed to load config: %v", err)
	}

	// Use cfg throughout the application
	fmt.Printf("Starting server on port %d\n", cfg.Port)
}

func loadConfig() (*Config, error) {
	dbPort, err := strconv.Atoi(os.Getenv("DB_PORT"))
	if err != nil {
		return nil, fmt.Errorf("invalid DB_PORT: %w", err)
	}

	port, err := strconv.Atoi(os.Getenv("PORT"))
	if err != nil {
		return nil, fmt.Errorf("invalid PORT: %w", err)
	}

	return &Config{
		DBHost:      os.Getenv("DB_HOST"),
		DBPort:      dbPort,
		DBUser:      os.Getenv("DB_USER"),
		DBPassword:  os.Getenv("DB_PASSWORD"),
		APIKey:      os.Getenv("API_KEY"),
		LogLevel:    os.Getenv("LOG_LEVEL"),
		Port:        port,
		Environment: os.Getenv("ENVIRONMENT"),
	}, nil
}

This pattern isolates parsing from validation. Notice that we convert string environment variables to their proper types immediately—a port number to an integer, for example. If the conversion fails, we return an error right there, before any downstream code tries to use an invalid value.

Check required values

Not all configuration is optional. A database host, API credentials, and a listening port are typically non-negotiable. After parsing, add a validation step that ensures required fields are present and sensible.

go
func (c *Config) Validate() error {
	if c.DBHost == "" {
		return fmt.Errorf("DB_HOST is required")
	}
	if c.DBPort == 0 {
		return fmt.Errorf("DB_PORT is required and must be > 0")
	}
	if c.DBUser == "" {
		return fmt.Errorf("DB_USER is required")
	}
	if c.DBPassword == "" {
		return fmt.Errorf("DB_PASSWORD is required")
	}
	if c.APIKey == "" {
		return fmt.Errorf("API_KEY is required")
	}
	if c.Port == 0 || c.Port > 65535 {
		return fmt.Errorf("PORT must be between 1 and 65535")
	}
	if c.LogLevel == "" {
		c.LogLevel = "info" // Set a sensible default
	}
	if c.Environment == "" {
		c.Environment = "development"
	}
	return nil
}

func main() {
	cfg, err := loadConfig()
	if err != nil {
		log.Fatalf("Failed to load config: %v", err)
	}

	if err := cfg.Validate(); err != nil {
		log.Fatalf("Config validation failed: %v", err)
	}

	fmt.Printf("Starting server on port %d\n", cfg.Port)
}

The Validate method is your safety net. It checks that required fields exist, that numeric fields are in sensible ranges, and sets reasonable defaults for optional fields. This is also where you might validate that a URL is well-formed, that a duration string is parseable, or that an enum value is one of an allowed set.

In a real service, you might have dozens of configuration values. A structured validation method keeps the checks maintainable and documents what you expect from the environment.

Log config without secrets

After validating, log your configuration so future debugging is easier. But never log secrets—database passwords, API keys, and tokens should stay off the console and out of log aggregators.

go
func (c *Config) LogSanitized() {
	log.Printf("Configuration loaded:")
	log.Printf("  DB_HOST: %s", c.DBHost)
	log.Printf("  DB_PORT: %d", c.DBPort)
	log.Printf("  DB_USER: %s", c.DBUser)
	log.Printf("  DB_PASSWORD: [REDACTED]")
	log.Printf("  API_KEY: [REDACTED]")
	log.Printf("  LOG_LEVEL: %s", c.LogLevel)
	log.Printf("  PORT: %d", c.Port)
	log.Printf("  ENVIRONMENT: %s", c.Environment)
}

func main() {
	cfg, err := loadConfig()
	if err != nil {
		log.Fatalf("Failed to load config: %v", err)
	}

	if err := cfg.Validate(); err != nil {
		log.Fatalf("Config validation failed: %v", err)
	}

	cfg.LogSanitized()

	fmt.Printf("Starting server on port %d\n", cfg.Port)
}

The LogSanitized method outputs configuration values but redacts sensitive strings. This is invaluable when troubleshooting deployments—you see that the database host is correct, the log level is set properly, and the port is in the right range, without exposing credentials in logs that third-party developers might see.

Many teams have learned this lesson the hard way: a secret leaked in a log file, discovered weeks later in a security audit. Make redaction automatic and easy.

Exit with a clear message

When validation fails, your error message should guide the person deploying your service toward the fix. Generic errors like "config error" are frustrating; specific messages are helpful.

go
func main() {
	cfg, err := loadConfig()
	if err != nil {
		log.Fatalf("Failed to load config: %v\nCheck that all required environment variables are set.", err)
	}

	if err := cfg.Validate(); err != nil {
		log.Fatalf(
			"Config validation failed: %v\n\nRequired environment variables:\n"+
				"  DB_HOST (database hostname)\n"+
				"  DB_PORT (numeric port, e.g., 5432)\n"+
				"  DB_USER (database username)\n"+
				"  DB_PASSWORD (database password)\n"+
				"  API_KEY (external API credential)\n"+
				"  PORT (service listening port, 1-65535)\n"+
				"  LOG_LEVEL (optional: debug, info, warn, error; default: info)\n"+
				"  ENVIRONMENT (optional: development, staging, production; default: development)\n",
			err,
		)
	}

	cfg.LogSanitized()
	fmt.Printf("Starting server on port %d in %s mode\n", cfg.Port, cfg.Environment)
}

This exit message is longer, but it saves time. A developer deploying to a new environment sees immediately which variables are missing and what they should contain. Compare this to the confusion of a vague "config invalid" error—the clear message is a gift to your future self and your teammates.

Putting it together: a complete example

Here is a fuller, production-ready pattern:

go
package main

import (
	"fmt"
	"log"
	"os"
	"strconv"
	"time"
)

type Config struct {
	DBHost            string
	DBPort            int
	DBUser            string
	DBPassword        string
	DBMaxConnections  int
	DBConnectTimeout  time.Duration
	APIKey            string
	LogLevel          string
	Port              int
	Environment       string
	GracefulShutdown  time.Duration
}

func loadConfig() (*Config, error) {
	dbPort, err := strconv.Atoi(os.Getenv("DB_PORT"))
	if err != nil {
		return nil, fmt.Errorf("invalid DB_PORT: %w", err)
	}

	port, err := strconv.Atoi(os.Getenv("PORT"))
	if err != nil {
		return nil, fmt.Errorf("invalid PORT: %w", err)
	}

	maxConns, err := strconv.Atoi(os.Getenv("DB_MAX_CONNECTIONS"))
	if err != nil {
		maxConns = 10
	}

	connTimeout := 5 * time.Second
	if envTimeout := os.Getenv("DB_CONNECT_TIMEOUT"); envTimeout != "" {
		parsed, err := time.ParseDuration(envTimeout)
		if err == nil {
			connTimeout = parsed
		}
	}

	gracefulShutdown := 30 * time.Second
	if envShutdown := os.Getenv("GRACEFUL_SHUTDOWN"); envShutdown != "" {
		parsed, err := time.ParseDuration(envShutdown)
		if err == nil {
			gracefulShutdown = parsed
		}
	}

	return &Config{
		DBHost:           os.Getenv("DB_HOST"),
		DBPort:           dbPort,
		DBUser:           os.Getenv("DB_USER"),
		DBPassword:       os.Getenv("DB_PASSWORD"),
		DBMaxConnections: maxConns,
		DBConnectTimeout: connTimeout,
		APIKey:           os.Getenv("API_KEY"),
		LogLevel:         os.Getenv("LOG_LEVEL"),
		Port:             port,
		Environment:      os.Getenv("ENVIRONMENT"),
		GracefulShutdown: gracefulShutdown,
	}, nil
}

func (c *Config) Validate() error {
	if c.DBHost == "" {
		return fmt.Errorf("DB_HOST is required")
	}
	if c.DBPort <= 0 || c.DBPort > 65535 {
		return fmt.Errorf("DB_PORT must be between 1 and 65535")
	}
	if c.DBUser == "" {
		return fmt.Errorf("DB_USER is required")
	}
	if c.DBPassword == "" {
		return fmt.Errorf("DB_PASSWORD is required")
	}
	if c.APIKey == "" {
		return fmt.Errorf("API_KEY is required")
	}
	if c.Port <= 0 || c.Port > 65535 {
		return fmt.Errorf("PORT must be between 1 and 65535")
	}
	if c.DBMaxConnections < 1 {
		c.DBMaxConnections = 10
	}
	if c.LogLevel == "" {
		c.LogLevel = "info"
	}
	if c.Environment == "" {
		c.Environment = "development"
	}
	return nil
}

func (c *Config) LogSanitized() {
	log.Printf("Configuration:")
	log.Printf("  DB_HOST: %s", c.DBHost)
	log.Printf("  DB_PORT: %d", c.DBPort)
	log.Printf("  DB_USER: %s", c.DBUser)
	log.Printf("  DB_PASSWORD: [REDACTED]")
	log.Printf("  DB_MAX_CONNECTIONS: %d", c.DBMaxConnections)
	log.Printf("  DB_CONNECT_TIMEOUT: %s", c.DBConnectTimeout)
	log.Printf("  API_KEY: [REDACTED]")
	log.Printf("  LOG_LEVEL: %s", c.LogLevel)
	log.Printf("  PORT: %d", c.Port)
	log.Printf("  ENVIRONMENT: %s", c.Environment)
	log.Printf("  GRACEFUL_SHUTDOWN: %s", c.GracefulShutdown)
}

func main() {
	cfg, err := loadConfig()
	if err != nil {
		log.Fatalf("Failed to load config: %v\nEnsure all required environment variables are set.", err)
	}

	if err := cfg.Validate(); err != nil {
		log.Fatalf(
			"Config validation failed: %v\n\nRequired:\n"+
				"  DB_HOST, DB_PORT, DB_USER, DB_PASSWORD\n"+
				"  API_KEY\n"+
				"  PORT (1-65535)\n\nOptional:\n"+
				"  DB_MAX_CONNECTIONS (default: 10)\n"+
				"  DB_CONNECT_TIMEOUT (default: 5s)\n"+
				"  LOG_LEVEL (default: info)\n"+
				"  ENVIRONMENT (default: development)\n"+
				"  GRACEFUL_SHUTDOWN (default: 30s)\n",
			err,
		)
	}

	cfg.LogSanitized()
	log.Printf("Ready to start. Listening on port %d in %s mode.", cfg.Port, cfg.Environment)
}

Common mistakes

  • Validating too late. If you check configuration inside route handlers or background workers, errors surface only when that code path runs. Validate in main, before the server starts accepting requests.
  • Logging secrets. Do not call fmt.Printf("%v", cfg) or write the entire config struct to logs. Use a sanitized method that explicitly redacts sensitive fields.
  • Unclear error messages. "Config error" tells you nothing. "DB_PORT must be between 1 and 65535, got: not-a-number" points you toward the fix immediately.
  • No defaults for optional fields. If a field is truly optional, set a sensible default in Validate rather than leaving it nil or empty. This prevents nil pointer dereferences later.
  • Mixing parsing and validation. Keep type conversion (parsing) separate from business logic validation. A numeric port that fails to parse is a parsing error; a port number outside the valid range is a validation error.
  • Ignoring environment variable naming conventions. Use SCREAMING_SNAKE_CASE for environment variables so they stand out from regular variables in your code and in shell sessions.

Key takeaways

  • Define a config struct and populate it in main before anything else runs. Type safety and explicitness prevent entire classes of bugs.
  • Separate parsing (converting strings to types) from validation (checking business logic). This makes errors easier to pinpoint.
  • Use a Validate method to check required fields, numeric ranges, and sensible defaults. Call it immediately after loading config.
  • Log your configuration after validation, but use a sanitized method that redacts passwords, API keys, and tokens.
  • Write clear, actionable error messages that list which variables are required and what format they should have. This saves debugging time for anyone deploying your service.
  • Fail fast and explicitly. A service that crashes at startup because of a missing environment variable is far preferable to one that crashes under load.
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