02 - Configuration
📋 Jump to TakeawaysEvery app needs configuration: database URLs, port numbers, API keys, feature flags. In Go, you don't need Viper, envconfig, or any external package. The standard library handles it.
Environment Variables
The simplest and most common approach. Works everywhere: local dev, Docker, Kubernetes, cloud platforms.
package main
import (
"fmt"
"os"
)
func main() {
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
dbURL := os.Getenv("DATABASE_URL")
if dbURL == "" {
fmt.Println("WARNING: DATABASE_URL not set")
}
fmt.Printf("starting on :%s\n", port)
}os.Getenv returns an empty string if the variable isn't set. No error, no panic. Always provide defaults for non-critical values.
A Config Struct
Don't scatter os.Getenv calls throughout your code. Load everything once into a struct at startup.
type Config struct {
Port string
DatabaseURL string
LogLevel string
Debug bool
}
func LoadConfig() Config {
return Config{
Port: getEnv("PORT", "8080"),
DatabaseURL: getEnv("DATABASE_URL", ""),
LogLevel: getEnv("LOG_LEVEL", "info"),
Debug: getEnv("DEBUG", "false") == "true",
}
}
func getEnv(key, fallback string) string {
if val := os.Getenv(key); val != "" {
return val
}
return fallback
}One function, one struct, all defaults in one place. Pass Config to whatever needs it: handlers, database, logger. No globals.
Required Variables
Some config values have no sensible default. Fail fast if they're missing. Use os.LookupEnv here instead of os.Getenv — os.Getenv returns an empty string both when the variable isn't set and when it's set to "". LookupEnv tells you the difference.
func mustGetEnv(key string) string {
val, ok := os.LookupEnv(key)
if !ok {
log.Fatalf("required environment variable %s is not set", key)
}
return val
}
func LoadConfig() Config {
return Config{
Port: getEnv("PORT", "8080"),
DatabaseURL: mustGetEnv("DATABASE_URL"), // crash if missing
LogLevel: getEnv("LOG_LEVEL", "info"),
}
}The app won't start without a database URL. That's intentional. Better to crash at startup than fail silently on the first request.
Command-Line Flags
For CLI tools or when you want to override config from the terminal.
import "flag"
func main() {
port := flag.String("port", "8080", "server port")
debug := flag.Bool("debug", false, "enable debug mode")
flag.Parse()
fmt.Printf("port=%s debug=%v\n", *port, *debug)
}go run . -port 3000 -debugFlags return pointers, so dereference with *port. The flag package handles -help automatically.
Combining Flags and Environment Variables
A common pattern: environment variables as the base, flags as overrides.
func LoadConfig() Config {
cfg := Config{
Port: getEnv("PORT", "8080"),
LogLevel: getEnv("LOG_LEVEL", "info"),
}
// Flags override env vars
flag.StringVar(&cfg.Port, "port", cfg.Port, "server port")
flag.StringVar(&cfg.LogLevel, "log-level", cfg.LogLevel, "log level")
flag.Parse()
return cfg
}flag.StringVar binds a flag to an existing string variable. In our example, the default value comes from the environment, and the flag overrides it if provided.
.env Files for Local Development
In production, environment variables are set by the platform. Locally, you can use a .env file:
PORT=3000
DATABASE_URL=postgres://localhost:5432/bookmarks
LOG_LEVEL=debug
DEBUG=trueLoad it in your shell before running:
export $(cat .env | xargs) && go run .Or add it to your Makefile (we'll cover Makefiles in a later lesson):
run:
export $(cat .env | xargs) && go run .Don't commit .env to git. Add it to .gitignore. Commit a .env.example with placeholder values instead.
Why Not Viper?
Viper is a popular Go config library. It supports YAML, JSON, TOML, environment variables, flags, remote config, and more. So why not use it?
- It pulls in 15+ transitive dependencies
- It uses
interface{}everywhere, so no type safety - It's global state by default —
viper.GetString("port")reads from a package-level singleton, so any part of your code can access or mutate config without it being passed explicitly - For most projects,
os.Getenv+flagcovers everything you need
If you actually need multi-format config files with hot reloading, Viper makes sense. For a web service that reads environment variables, it's overkill.
Applying to Our Project
Update main.go to use a config struct:
package main
import (
"flag"
"fmt"
"log"
"net/http"
"os"
)
type Config struct {
Port string
LogLevel string
}
func getEnv(key, fallback string) string {
if val := os.Getenv(key); val != "" {
return val
}
return fallback
}
func LoadConfig() Config {
cfg := Config{
Port: getEnv("PORT", "8080"),
LogLevel: getEnv("LOG_LEVEL", "info"),
}
flag.StringVar(&cfg.Port, "port", cfg.Port, "server port")
flag.StringVar(&cfg.LogLevel, "log-level", cfg.LogLevel, "log level")
flag.Parse()
return cfg
}
func main() {
cfg := LoadConfig()
mux := http.NewServeMux()
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("ok"))
})
fmt.Printf("listening on :%s (log level: %s)\n", cfg.Port, cfg.LogLevel)
log.Fatal(http.ListenAndServe(":"+cfg.Port, mux))
}# Using env vars
PORT=3000 go run .
# Using flags
go run . -port 3000 -log-level debug
# Flags override env vars
PORT=8080 go run . -port 3000
# → listens on 3000Key Takeaways
os.Getenv+ a config struct is all you need for most Go services- Load config once at startup, pass the struct around. No globals
- Use
mustGetEnvfor required values — useos.LookupEnvand check the boolean second argument to distinguish "not set" from "set to empty". Fail fast at startup flagpackage for CLI tools or flag overrides.envfiles for local dev, environment variables for production- Don't reach for Viper unless you actually need its features