Configuration: Flags, Environment & Secrets
The same program runs on your laptop, on a test server and in production. The port, the database address, the log level and the API keys are different in each place, but the code must be identical. Configuration is everything that changes between these environments, and it must live outside the code.
In this lesson you will read command-line flags with the flag package, read environment variables, gather everything into one validated Config struct with sensible defaults, report all mistakes at once, and keep secrets out of your code, your Git history and your logs.
What belongs in configuration
| Configuration (changes per environment) | Not configuration (same everywhere) |
|---|---|
| Listen address and port | The list of API routes |
| Database URL and password | The GST calculation rules |
| Log level | The JSON field names |
| Timeouts and worker counts | Business validation such as "qty must be positive" |
| API keys for SMS or payment gateways | Error messages |
A useful test: could you open-source the code right now without leaking anything? If a password or a production address is written in a .go file, the answer is no.
Flags with the flag package
Command-line flags suit tools and local runs. The flag package defines each flag with a name, a default and a help text, then flag.Parse() reads os.Args:
package main
import (
"flag"
"fmt"
"time"
)
func main() {
addr := flag.String("addr", ":8080", "listen `address`")
workers := flag.Int("workers", 4, "number of background workers")
timeout := flag.Duration("timeout", 10*time.Second, "shutdown timeout")
debug := flag.Bool("debug", false, "enable debug logs")
flag.Parse()
fmt.Println(*addr, *workers, *timeout, *debug)
}go build -o ordersd .
./ordersd -addr :9000 -debug -timeout 30s
./ordersd -h:9000 4 30s true
Usage of ./ordersd:
-addr address
listen address (default ":8080")
-debug
enable debug logs
-timeout duration
shutdown timeout (default 10s)
-workers int
number of background workers (default 4)Each function returns a pointer, which Parse fills in. flag.Duration understands values such as 30s, 2m or 1h30m, so nobody has to remember whether a number means seconds or milliseconds. The help text is generated for free, and a word in backquotes, like `address`, becomes the name shown after the flag.
Environment variables
Servers, Docker containers and cloud platforms pass settings as environment variables. os.Getenv("PORT") returns an empty string when the variable is missing; os.LookupEnv also tells you whether it was set at all, so you can tell "not set" apart from "set to empty":
port, ok := os.LookupEnv("PORT")
if !ok {
port = "8080"
}Use flags for tools that people run by hand, and environment variables for services that a platform starts. Many services support both: environment variables for deployment, flags to override them while testing.
Step by step: a validated Config struct
Scattering os.Getenv calls through the code makes it impossible to see what a program needs. Read everything once, at start-up, into one struct.
- Define a
Configstruct with a field for every setting, using real types such astime.Durationandint. - Write
Load, which starts from defaults and overrides them from the environment. It takes a lookup function instead of callingos.LookupEnvdirectly, so tests can pass a map. - Parse and check each value, collecting every problem instead of stopping at the first one.
- Return the problems combined with
errors.Join(see Errors: Wrapping, Is & As), and stop the program inmainif there are any.
// internal/config/config.go
package config
type Config struct {
Addr string
DatabaseURL string
ShutdownTimeout time.Duration
Workers int
}
func Load(lookup func(string) (string, bool)) (Config, error) {
cfg := Config{Addr: ":8080", ShutdownTimeout: 10 * time.Second, Workers: 4}
var errs []error
if v, ok := lookup("ADDR"); ok {
cfg.Addr = v
}
if v, ok := lookup("DATABASE_URL"); ok && v != "" {
cfg.DatabaseURL = v
} else {
errs = append(errs, errors.New("DATABASE_URL is required"))
}
if v, ok := lookup("SHUTDOWN_TIMEOUT"); ok {
d, err := time.ParseDuration(v)
if err != nil {
errs = append(errs, fmt.Errorf("SHUTDOWN_TIMEOUT: %w", err))
}
cfg.ShutdownTimeout = d
}
if v, ok := lookup("WORKERS"); ok {
n, err := strconv.Atoi(v)
if err != nil || n < 1 {
errs = append(errs, fmt.Errorf("WORKERS must be a positive number, got %q", v))
}
cfg.Workers = n
}
return cfg, errors.Join(errs...)
}// cmd/ordersd/main.go
cfg, err := config.Load(os.LookupEnv)
if err != nil {
fmt.Fprintf(os.Stderr, "configuration errors:\n%v\n", err)
os.Exit(2)
}Run it with three mistakes at once (in Git Bash or Linux, NAME=value command sets a variable for one command):
SHUTDOWN_TIMEOUT=ten WORKERS=0 ./ordersdconfiguration errors: DATABASE_URL is required SHUTDOWN_TIMEOUT: time: invalid duration "ten" WORKERS must be a positive number, got "0"
errors.Join puts each error on its own line. The person deploying sees every problem in one go instead of fixing them one restart at a time. Failing at start-up is a feature: a service that starts with a broken setting fails later, at the worst moment. In a test, pass a fake lookup built from a map: Load(func(k string) (string, bool) { v, ok := env[k]; return v, ok }).
Secrets
Secrets are configuration that would cause damage if leaked: database passwords, payment gateway keys, signing keys. Rules every team follows:
- Never write secrets in code or commit them to Git, not even in a private repository. Add files such as
.envto.gitignore. - Pass them as environment variables, or as files mounted by the platform. Docker and Kubernetes secrets usually appear as files, for example under
/run/secrets/, so support a_FILEvariant such asDATABASE_URL_FILE, read withos.ReadFileandstrings.TrimSpace. - Never log them. When you log the configuration, hide the password.
The net/url package has a helper for exactly this. Redacted replaces the password in a URL:
u, err := url.Parse("postgres://app:s3cret@db.internal:5432/orders")
if err != nil {
log.Fatal(err)
}
slog.Info("config loaded", "database", u.Redacted(), "workers", 4)2026/11/02 10:15:04 INFO config loaded database=postgres://app:xxxxx@db.internal:5432/orders workers=4
(The date and time will differ.) The person reading the logs can still see which database the service uses, but not how to log in to it.
Common mistakes
- Reading environment variables all over the code. Load once into a
Configand pass it in. - Silently falling back on bad values.
WORKERS=abcshould stop the program, not quietly use 4. - Logging the whole config struct. Secrets end up in log storage; log selected, redacted fields.
- Committing
.envfiles with real passwords. Rotate any secret that ever reached Git.
Key points
- Configuration is whatever changes between environments; keep it out of the code.
flagdefines typed flags with defaults and automatic-hhelp;flag.Durationparses values like30s.os.LookupEnvtells "not set" apart from "empty"; services usually read environment variables.- Load everything once into a validated
Config, report all problems witherrors.Join, and fail fast. - Pass secrets through the environment or mounted files, never Git, and redact them in logs with helpers such as
url.URL.Redacted.
Exercise
Extend Load so the database URL can also come from a file: if DATABASE_URL_FILE is set, read that file, trim spaces and newlines, and use its content as DatabaseURL. If both DATABASE_URL and DATABASE_URL_FILE are set, report an error saying only one may be used.
Show solution
urlVal, hasURL := lookup("DATABASE_URL")
filePath, hasFile := lookup("DATABASE_URL_FILE")
switch {
case hasURL && hasFile:
errs = append(errs, errors.New("set DATABASE_URL or DATABASE_URL_FILE, not both"))
case hasFile:
data, err := os.ReadFile(filePath)
if err != nil {
errs = append(errs, fmt.Errorf("DATABASE_URL_FILE: %w", err))
} else {
cfg.DatabaseURL = strings.TrimSpace(string(data))
}
case hasURL && urlVal != "":
cfg.DatabaseURL = urlVal
default:
errs = append(errs, errors.New("DATABASE_URL is required"))
}This switch replaces the earlier DATABASE_URL check. strings.TrimSpace removes the newline that editors and secret tools often add at the end of a file. Refusing both variables at once avoids confusion about which value wins.
Quick Quiz
1. Why use os.LookupEnv instead of os.Getenv?
LookupEnv returns a second boolean value.
2. What does flag.Duration("timeout", 10*time.Second, "...") accept on the command line?
Durations are parsed with units.
3. Why collect configuration errors with errors.Join instead of returning the first one?
Fixing all problems at once saves repeated restarts.
4. How should a database password reach a production service?
Secrets stay outside code and version control.
Learn Go with a mentor
Free lessons get you started. Our Programming & Software Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
