Environment variables and configuration in Go for backend applications
How to read environment variables in Go, configure per environment, validate and separate secrets. Explicit configuration with no magic.

Configuration in Go is boring. And that is exactly what you want.
No magic auto-configuration. No framework scanning annotations to inject values. No application.yml with profiles resolved in cascade based on the environment, the lunar phase, and the CI server’s mood. In Go, you read an environment variable, assign it to a field, and get on with your life. If it is missing, the program fails at startup, not at three in the morning when a user makes the request that hits that code for the first time.
That simplicity has a cost: you have to wire the plumbing yourself. But that cost is paid once, at the start of the project, and then you forget about it. What follows is a pattern I use in all my Go services that covers 95% of cases without adding unnecessary complexity.
os.Getenv and os.LookupEnv: the basics
Go has two functions in the standard library for reading environment variables. The difference between them is subtle but important.
os.Getenv returns the value of the variable or an empty string if it does not exist:
package main
import (
"fmt"
"os"
)
func main() {
port := os.Getenv("PORT")
fmt.Println("Port:", port) // "" if PORT is not defined
}The problem is that you cannot distinguish between “the variable exists and its value is empty” and “the variable does not exist”. In most cases it does not matter, but when you need to know if someone explicitly configured something, you need os.LookupEnv:
func main() {
port, exists := os.LookupEnv("PORT")
if !exists {
port = "8080"
}
fmt.Println("Port:", port)
}os.LookupEnv returns the value and a boolean indicating whether the variable exists. This lets you implement default values explicitly: if the variable is not set, you use the default. If it is set but empty, you respect that empty value.
For simple configurations (a script, a small CLI tool), these two functions are all you need. Nothing more. But as soon as your service has more than five or six configuration variables, reading them one by one in main() becomes a repetitive and hard-to-maintain block of code.
The Config struct pattern: centralized configuration
The natural step is to group all configuration in a struct and create a function that builds it from the environment. This pattern is so common in Go that it is practically an unwritten standard.
// internal/config/config.go
package config
import (
"fmt"
"os"
"strconv"
)
type Config struct {
Port int
DatabaseURL string
LogLevel string
Environment string
}
func Load() (*Config, error) {
port, err := getEnvInt("PORT", 8080)
if err != nil {
return nil, fmt.Errorf("invalid PORT: %w", err)
}
dbURL, err := getEnvRequired("DATABASE_URL")
if err != nil {
return nil, err
}
return &Config{
Port: port,
DatabaseURL: dbURL,
LogLevel: getEnv("LOG_LEVEL", "info"),
Environment: getEnv("ENVIRONMENT", "development"),
}, nil
}
func getEnv(key, fallback string) string {
if val, ok := os.LookupEnv(key); ok {
return val
}
return fallback
}
func getEnvRequired(key string) (string, error) {
val, ok := os.LookupEnv(key)
if !ok || val == "" {
return "", fmt.Errorf("required environment variable %s is not set", key)
}
return val, nil
}
func getEnvInt(key string, fallback int) (int, error) {
val, ok := os.LookupEnv(key)
if !ok {
return fallback, nil
}
parsed, err := strconv.Atoi(val)
if err != nil {
return 0, fmt.Errorf("cannot parse %s=%q as int: %w", key, val, err)
}
return parsed, nil
}And in your main.go:
func main() {
cfg, err := config.Load()
if err != nil {
log.Fatalf("configuration error: %v", err)
}
// cfg is passed as a dependency to handlers, services, etc.
log.Printf("Starting server on :%d [%s]", cfg.Port, cfg.Environment)
}Notice several things:
Load()returns an error. It does not calllog.Fatalinternally. The decision of what to do with a configuration error is made bymain(), not the config package.- Required variables fail explicitly. If
DATABASE_URLis missing, the service does not start. It does not keep running with an empty string waiting for something to explode. - Default values are visible. They are not in a separate YAML file or a constant in another package. They are right there, next to the read, where you can see them.
- The type is correct.
Portis anint, not astring. The conversion happens once, when the config is loaded. The rest of the code works with native types.
This pattern is the one I use as a base. For a service with 10-15 configuration variables, it works perfectly without external dependencies. If you are interested in how this integrates into the overall project structure, check out the article on project structure.
Default values and validation
Default values should follow a simple principle: the default makes it work locally without configuring anything. The port is 8080, the log level is debug, the environment is development. If a developer clones the repo and runs go run ., it should start without having to create files or export variables.
But not everything can have a default. The database URL should not have a default value because that means someone could start the service and accidentally connect to a database that is not theirs. Secrets either. Critical variables must be required, and the service must fail loudly if they are missing.
For more complex validations, add a Validate() method to the struct:
func (c *Config) Validate() error {
if c.Port < 1 || c.Port > 65535 {
return fmt.Errorf("PORT must be between 1 and 65535, got %d", c.Port)
}
validEnvs := map[string]bool{
"development": true,
"staging": true,
"production": true,
}
if !validEnvs[c.Environment] {
return fmt.Errorf("ENVIRONMENT must be one of [development, staging, production], got %q", c.Environment)
}
if c.Environment == "production" && c.LogLevel == "debug" {
return fmt.Errorf("LOG_LEVEL=debug is not allowed in production")
}
return nil
}And in Load():
func Load() (*Config, error) {
cfg := &Config{
// ... load values ...
}
if err := cfg.Validate(); err != nil {
return nil, fmt.Errorf("config validation failed: %w", err)
}
return cfg, nil
}Validation happens at load time, not when someone uses the value. If PORT is 99999, the program never even tries to call ListenAndServe. It fails at startup, with a clear message of what is wrong and what was expected.
godotenv for local development
In production, environment variables are injected by the orchestrator (Docker, Kubernetes, your cloud platform). Locally, manually exporting variables every time you open a terminal is tedious. That is what godotenv is for.
go get github.com/joho/godotenvCreate a .env file at the root of the project:
PORT=3000
DATABASE_URL=postgres://postgres:postgres@localhost:5432/myapp?sslmode=disable
LOG_LEVEL=debug
ENVIRONMENT=developmentAnd load the file before reading the configuration:
package main
import (
"log"
"github.com/joho/godotenv"
"my-project/internal/config"
)
func main() {
// Locally, load .env. In production, the file does not exist and nothing happens.
_ = godotenv.Load()
cfg, err := config.Load()
if err != nil {
log.Fatalf("configuration error: %v", err)
}
log.Printf("Starting server on :%d", cfg.Port)
}Important details:
- Ignore the error from
godotenv.Load(). If the file does not exist (as in production), it simply does nothing. You do not want your service to fail in production because a.envis missing. .envdoes not override existing variables. If a variable is already defined in the environment,godotenv.Load()respects it. This is correct: the system environment takes priority..envgoes in.gitignore. Always. Without exceptions. The.envfile contains local database URLs, development tokens, and developer-specific configuration. It does not belong in the repository.
If you want an example file that does go in the repo, create a .env.example with dummy values:
PORT=8080
DATABASE_URL=postgres://user:password@localhost:5432/dbname?sslmode=disable
LOG_LEVEL=info
ENVIRONMENT=developmentThis documents what variables the project needs without exposing real values.
envconfig and cleanenv: struct-based configuration
When your service has 20 or more configuration variables, the helper functions getEnv, getEnvInt, getEnvRequired become repetitive. That is the moment to use a library that maps environment variables directly to a struct using tags.
kelseyhightower/envconfig
The classic library. Simple, mature, with few dependencies:
go get github.com/kelseyhightower/envconfigpackage config
import (
"time"
"github.com/kelseyhightower/envconfig"
)
type Config struct {
Port int `envconfig:"PORT" default:"8080"`
DatabaseURL string `envconfig:"DATABASE_URL" required:"true"`
LogLevel string `envconfig:"LOG_LEVEL" default:"info"`
Environment string `envconfig:"ENVIRONMENT" default:"development"`
ReadTimeout time.Duration `envconfig:"READ_TIMEOUT" default:"5s"`
WriteTimeout time.Duration `envconfig:"WRITE_TIMEOUT" default:"10s"`
MaxConnections int `envconfig:"MAX_CONNECTIONS" default:"25"`
CacheEnabled bool `envconfig:"CACHE_ENABLED" default:"true"`
}
func Load() (*Config, error) {
var cfg Config
if err := envconfig.Process("", &cfg); err != nil {
return nil, err
}
return &cfg, nil
}The first argument of Process is a prefix. If you pass "APP", it looks for APP_PORT, APP_DATABASE_URL, etc. Passing an empty string looks for variables without a prefix. The prefix is useful when running several services in the same environment and you want to avoid name collisions.
envconfig supports types natively: int, bool, float64, time.Duration, []string (comma-separated), and types that implement encoding.TextUnmarshaler. That covers practically everything.
ilyakaznacheev/cleanenv
A more modern alternative with support for multiple sources (YAML, TOML, ENV):
go get github.com/ilyakaznacheev/cleanenvpackage config
import "github.com/ilyakaznacheev/cleanenv"
type Config struct {
Port int `env:"PORT" env-default:"8080"`
DatabaseURL string `env:"DATABASE_URL" env-required:"true"`
LogLevel string `env:"LOG_LEVEL" env-default:"info"`
Environment string `env:"ENVIRONMENT" env-default:"development"`
}
func Load() (*Config, error) {
var cfg Config
if err := cleanenv.ReadEnv(&cfg); err != nil {
return nil, err
}
return &cfg, nil
}cleanenv can also read from a configuration file and combine values with environment variables, which can be useful if you have complex defaults that do not fit in a tag.
My recommendation: if you only read environment variables, envconfig is sufficient and lighter. If you need to combine configuration files with environment variables, cleanenv is worth it. If you have fewer than 10 variables, manual helper functions are perfectly valid and you save yourself a dependency.
Separating secrets from configuration
This is a mistake I see constantly: treating secrets the same as the rest of the configuration. The database URL, API keys, JWT tokens… all mixed into the same environment variables, with the same access level, in the same .env.
Locally, this works. In production, it is a security problem. Secrets need different treatment:
- Rotation. An API key should be rotatable without redeploying. A standard environment variable requires restarting the process.
- Auditing. You need to know who accessed a secret and when. Environment variables leave no trail.
- Access control. Not all developers should be able to see production secrets. If they are in a shared
.envor in CI pipeline variables, anyone with repository access can see them.
The solution is not complicated. Separate secrets from the rest of the configuration at the code level:
type Config struct {
Port int
LogLevel string
Environment string
}
type Secrets struct {
DatabaseURL string
JWTSecret string
StripeAPIKey string
}Locally, both can come from the .env. But in production, Config comes from regular environment variables and Secrets comes from a secrets manager: AWS Secrets Manager, HashiCorp Vault, GCP Secret Manager, Azure Key Vault, or Kubernetes secrets.
func LoadSecrets(env string) (*Secrets, error) {
if env == "development" {
return &Secrets{
DatabaseURL: os.Getenv("DATABASE_URL"),
JWTSecret: os.Getenv("JWT_SECRET"),
StripeAPIKey: os.Getenv("STRIPE_API_KEY"),
}, nil
}
// In production, read from a secrets manager
return loadFromSecretsManager()
}This separation forces you to think about what is a secret and what is not. If PORT=8080 leaks, nothing bad happens. If DATABASE_URL=postgres://admin:supersecret@prod-db:5432/app leaks, you have a problem. Treating them differently in code reflects that they are different in reality.
Configuration per environment: dev, staging, production
Per-environment configuration should not be solved with different configuration files for each environment (config.dev.yaml, config.staging.yaml, config.prod.yaml). That pattern is fragile: the files get out of sync, someone adds a variable to the development file and forgets to add it to production, and the error only appears at deploy time.
The idiomatic way in Go (and in Twelve-Factor Apps in general) is that configuration comes from the environment, and the environment is injected by whoever deploys:
- Locally:
.envwithgodotenv. - Docker: variables in
docker-compose.ymlor--env-file. - Kubernetes: ConfigMaps and Secrets mounted as environment variables.
- Cloud: service environment variables (ECS task definitions, Cloud Run env, etc.).
# docker-compose.yml
services:
api:
build: .
environment:
- PORT=8080
- DATABASE_URL=postgres://postgres:postgres@db:5432/myapp?sslmode=disable
- LOG_LEVEL=debug
- ENVIRONMENT=development
ports:
- "8080:8080"If you want to see how this integrates into a complete Docker container, I cover it in the article on dockerizing a Go API.
The key point: your Go code does not know or care where the variables come from. It just calls os.Getenv or uses the configuration struct. The injection mechanism is the responsibility of the runtime environment, not the application.
If you need different behavior per environment (for example, disabling rate limiting in development or using a structured logger only in production), make it explicit:
func setupLogger(cfg *config.Config) *slog.Logger {
if cfg.Environment == "production" {
return slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: parseLogLevel(cfg.LogLevel),
}))
}
return slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelDebug,
}))
}The environment determines the behavior, but the logic is transparent. No hidden profiles, no automatic activations. If you want to know what happens in production, you read the if and you know.
Testing with different configurations
Testing code that depends on configuration is straightforward if you have followed the struct pattern. Instead of modifying environment variables in tests (which is fragile and causes race conditions if you run tests in parallel), you pass the struct directly with the values you want:
func TestServiceWithCustomConfig(t *testing.T) {
cfg := &config.Config{
Port: 9090,
LogLevel: "debug",
Environment: "test",
}
svc := service.New(cfg)
// ... assertions ...
}If any test needs to modify environment variables (for example, to test the Load() function itself), use t.Setenv, available since Go 1.17:
func TestLoadConfig(t *testing.T) {
t.Setenv("PORT", "3000")
t.Setenv("DATABASE_URL", "postgres://localhost:5432/testdb")
cfg, err := config.Load()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cfg.Port != 3000 {
t.Errorf("expected port 3000, got %d", cfg.Port)
}
}t.Setenv is important because it restores the original value of the variable when the test ends. It does not contaminate the environment for other tests. And if you try to use it in a test running with t.Parallel(), Go panics at compile time. This is correct: modifying global environment variables from parallel tests is a recipe for flaky tests.
To test validation:
func TestLoadConfigMissingRequired(t *testing.T) {
// We do not set DATABASE_URL
t.Setenv("PORT", "8080")
_, err := config.Load()
if err == nil {
t.Fatal("expected error for missing DATABASE_URL, got nil")
}
}
func TestLoadConfigInvalidPort(t *testing.T) {
t.Setenv("PORT", "not-a-number")
t.Setenv("DATABASE_URL", "postgres://localhost/testdb")
_, err := config.Load()
if err == nil {
t.Fatal("expected error for invalid PORT, got nil")
}
}These tests are fast, need no external infrastructure, and validate that your configuration fails in the right way. To learn more about testing patterns in Go, check the article on REST API with Go, which includes integration tests with injected configuration.
Common mistakes: hardcoded values, missing validation
These are the configuration mistakes I have seen most often in Go projects in production.
Hardcoding values that should be configurable
// Bad
db, err := sql.Open("postgres", "postgres://localhost:5432/mydb")
// Good
db, err := sql.Open("postgres", cfg.DatabaseURL)If there is a string literal that changes between environments, it should be a configuration variable. Full stop. The usual candidates: URLs to external services, timeouts, pool sizes, feature flags, API keys (which should also be secrets).
Not validating at startup
// Bad: fails at 3 AM when someone makes a request
func (h *Handler) CreateOrder(w http.ResponseWriter, r *http.Request) {
apiKey := os.Getenv("STRIPE_API_KEY") // may be empty
client := stripe.NewClient(apiKey) // fails here, silently or not
// ...
}
// Good: fails at startup
func main() {
cfg, err := config.Load() // validates that STRIPE_API_KEY exists
if err != nil {
log.Fatalf("config: %v", err)
}
stripeClient := stripe.NewClient(cfg.StripeAPIKey)
handler := NewHandler(stripeClient)
}All configuration is read and validated once, at startup. If something is missing, the process does not even start. A clear error in the deployment log is better than a cryptic error at three in the morning.
Reading environment variables outside the configuration layer
// Bad: os.Getenv scattered throughout the code
func (s *Service) DoSomething() {
if os.Getenv("ENVIRONMENT") == "production" {
// ...
}
}
// Good: configuration injected as a dependency
func (s *Service) DoSomething() {
if s.cfg.Environment == "production" {
// ...
}
}If os.Getenv appears outside the config package, it is a red flag. It means configuration is scattered and you cannot know what variables your application needs without searching through all the code. Centralize the reading in a single place.
Not having a .env.example
If a new developer clones the project and does not know what variables it needs, they will lose time. A .env.example with all variables documented (and with dummy values for secrets) is the most useful documentation you can have. It stays up to date because it fails if it is not.
Configuration should be the most boring part of your service
After having set up configuration in quite a few Go services, the pattern that works best is always the same: a struct that defines everything the service needs, loads from environment variables at startup, validates before continuing, and injects as a dependency to the rest of the code. Secrets separated from ordinary configuration, .env only locally with godotenv, and the runtime environment (Docker, Kubernetes, cloud) injecting values in production.
No magic, no auto-configuration, no hidden profiles. You read from the environment, validate, and start up. If something is missing, the service does not start and tells you clearly. That is all.
If managing your service’s configuration feels exciting, you are probably overcomplicating it. Configuration should be invisible: it works or fails fast at startup. Nothing more.


