package config
import (
"fmt"
"os"
"strconv"
"time"
)
func getEnv(key, fallback string) string {
if v, ok := os.LookupEnv(key); ok && v != "" {
return v
}
return fallback
}
func required(key string) (string, error) {
v, ok := os.LookupEnv(key)
if !ok || v == "" {
return "", fmt.Errorf("%s is required", key)
}
return v, nil
}
func getInt(key string, fallback int) (int, error) {
raw, ok := os.LookupEnv(key)
if !ok || raw == "" {
return fallback, nil
}
n, err := strconv.Atoi(raw)
if err != nil {
return 0, fmt.Errorf("%s: invalid int %q", key, raw)
}
return n, nil
}
func getBool(key string, fallback bool) (bool, error) {
raw, ok := os.LookupEnv(key)
if !ok || raw == "" {
return fallback, nil
}
b, err := strconv.ParseBool(raw)
if err != nil {
return false, fmt.Errorf("%s: invalid bool %q", key, raw)
}
return b, nil
}
func getDuration(key string, fallback time.Duration) (time.Duration, error) {
raw, ok := os.LookupEnv(key)
if !ok || raw == "" {
return fallback, nil
}
d, err := time.ParseDuration(raw)
if err != nil {
return 0, fmt.Errorf("%s: invalid duration %q", key, raw)
}
return d, nil
}
package config
import (
"errors"
"fmt"
"time"
)
type Config struct {
Env string
Port int
DatabaseURL string
LogJSON bool
ReadTimeout time.Duration
}
func Load() (*Config, error) {
var errs []error
cfg := &Config{Env: getEnv("APP_ENV", "development")}
port, err := getInt("PORT", 8080)
errs = append(errs, err)
cfg.Port = port
dbURL, err := required("DATABASE_URL")
errs = append(errs, err)
cfg.DatabaseURL = dbURL
logJSON, err := getBool("LOG_JSON", true)
errs = append(errs, err)
cfg.LogJSON = logJSON
timeout, err := getDuration("READ_TIMEOUT", 15*time.Second)
errs = append(errs, err)
cfg.ReadTimeout = timeout
if err := errors.Join(errs...); err != nil {
return nil, err
}
if err := cfg.Validate(); err != nil {
return nil, err
}
return cfg, nil
}
func (c *Config) Validate() error {
var errs []error
if c.Port < 1 || c.Port > 65535 {
errs = append(errs, fmt.Errorf("PORT out of range: %d", c.Port))
}
switch c.Env {
case "development", "staging", "production":
default:
errs = append(errs, fmt.Errorf("APP_ENV invalid: %q", c.Env))
}
if c.ReadTimeout <= 0 {
errs = append(errs, errors.New("READ_TIMEOUT must be positive"))
}
return errors.Join(errs...)
}
package main
import (
"fmt"
"log"
"net/http"
"example.com/app/config"
)
func main() {
cfg, err := config.Load()
if err != nil {
log.Fatalf("configuration error:\n%v", err)
}
log.Printf("starting in %s mode on port %d", cfg.Env, cfg.Port)
srv := &http.Server{
Addr: fmt.Sprintf(":%d", cfg.Port),
ReadTimeout: cfg.ReadTimeout,
Handler: newRouter(cfg),
}
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("server failed: %v", err)
}
}
This snippet shows a small, dependency-free configuration loader for Go services that follows the twelve-factor convention of reading everything from the process environment. The design centers on a single Config struct that is populated once at startup, validated, and then treated as immutable for the lifetime of the process.
In config.go, the Load function builds a Config by delegating each field to a small set of typed helpers. getEnv returns a string with a fallback, while getInt, getBool, and getDuration wrap the standard library parsers (strconv.Atoi, strconv.ParseBool, and time.ParseDuration) so that a malformed value produces a clear error instead of a silent zero. Grouping the parsing this way keeps Load readable and makes the defaults visible in one place, which matters because the default is the behavior a developer gets when they forget to set a variable.
The helpers in env.go return an error rather than panicking, and Load accumulates any parse failures. The required helper enforces that a variable must be present, which is important for secrets like DATABASE_URL where a wrong default is more dangerous than a crash. The loader follows a fail-fast philosophy: it is better for a service to refuse to start with a precise message than to boot with a misconfigured timeout or a database pointing at localhost in production.
Validate performs cross-field checks that the parsers cannot express, such as ensuring Port is in range and that Env is one of the known values. Returning a joined error via errors.Join means every problem surfaces in a single startup log line rather than forcing the operator to fix one variable, restart, and discover the next.
The trade-off is a little boilerplate per field compared to reflection-based libraries, but the explicit approach keeps types obvious, avoids struct tags, and produces error messages that name the exact variable. main.go shows the intended usage: load, log a fatal error on failure, and pass the value object down. A common pitfall this avoids is scattering os.Getenv calls throughout the codebase, which makes defaults inconsistent and untestable; here all environment access is confined to the loader.
Related snips
package api
import (
"net/http"
"runtime/debug"
)
Expose build metadata for debugging deploys
package dbutil
import (
"context"
"github.com/jackc/pgconn"
Retry Postgres serialization failures with bounded attempts
class SignupForm
include ActiveModel::Model
include ActiveModel::Attributes
attribute :account_name, :string
attribute :email, :string
Shallow Controller, Deep Params: Form Object Pattern
#!/usr/bin/env bash
set -euo pipefail
export VAULT_ADDR="https://vault.internal:8200"
export VAULT_TOKEN="${VAULT_TOKEN:?missing VAULT_TOKEN}"
Secrets management with environment isolation and Vault
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Form Validation Example</title>
<style>
HTML forms with validation and accessibility
package files
import (
"context"
"time"
Presigned S3 upload URLs (AWS SDK v2)
Share this code
Here's the card — post it anywhere.