EthicsComputerCourses
Join training +91 90532 10052

Configuration: Flags, Environment & Secrets

Go · Lesson 40 of 43 Advanced 7 min read Updated

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 portThe list of API routes
Database URL and passwordThe GST calculation rules
Log levelThe JSON field names
Timeouts and worker countsBusiness validation such as "qty must be positive"
API keys for SMS or payment gatewaysError 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:

Go
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)
}
Terminal
go build -o ordersd .
./ordersd -addr :9000 -debug -timeout 30s
./ordersd -h
Output
: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":

Go
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.

  1. Define a Config struct with a field for every setting, using real types such as time.Duration and int.
  2. Write Load, which starts from defaults and overrides them from the environment. It takes a lookup function instead of calling os.LookupEnv directly, so tests can pass a map.
  3. Parse and check each value, collecting every problem instead of stopping at the first one.
  4. Return the problems combined with errors.Join (see Errors: Wrapping, Is & As), and stop the program in main if there are any.
Go
// 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...)
}
Go
// 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):

Terminal
SHUTDOWN_TIMEOUT=ten WORKERS=0 ./ordersd
Output
configuration 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 .env to .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 _FILE variant such as DATABASE_URL_FILE, read with os.ReadFile and strings.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:

Go
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)
Output
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 Config and pass it in.
  • Silently falling back on bad values. WORKERS=abc should stop the program, not quietly use 4.
  • Logging the whole config struct. Secrets end up in log storage; log selected, redacted fields.
  • Committing .env files with real passwords. Rotate any secret that ever reached Git.

Key points

  • Configuration is whatever changes between environments; keep it out of the code.
  • flag defines typed flags with defaults and automatic -h help; flag.Duration parses values like 30s.
  • os.LookupEnv tells "not set" apart from "empty"; services usually read environment variables.
  • Load everything once into a validated Config, report all problems with errors.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
Go
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?

2. What does flag.Duration("timeout", 10*time.Second, "...") accept on the command line?

3. Why collect configuration errors with errors.Join instead of returning the first one?

4. How should a database password reach a production service?

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