Variables d'entorn i configuració en Go per a aplicacions backend
Com llegir variables d'entorn en Go, configurar per entorns, validar i separar secrets. Configuració explícita i sense màgia.

La configuració en Go és avorrida. I això és exactament el que vols.
No hi ha autoconfiguració màgica. No hi ha un framework que escanegi anotacions per injectar valors. No hi ha un application.yml amb perfils que es resolen en cascada segons l’entorn, la fase lunar i l’humor del servidor de CI. En Go, llegeixes una variable d’entorn, l’assignes a un camp i continues amb la teva vida. Si falta, el programa falla en arrencar, no a les tres de la matinada quan un usuari fa la petició que toca aquell codi per primera vegada.
Aquesta simplicitat té un cost: has de muntar la fontaneria tu. Però aquest cost es paga una vegada, a l’inici del projecte, i després t’oblides. El que ve a continuació és un patró que faig servir en tots els meus serveis Go i que cobreix el 95% dels casos sense afegir complexitat innecessària.
os.Getenv i os.LookupEnv: el bàsic
Go té dues funcions a la llibreria estàndard per llegir variables d’entorn. La diferència entre elles és subtil però important.
os.Getenv retorna el valor de la variable o una cadena buida si no existeix:
package main
import (
\"fmt\"
\"os\"
)
func main() {
port := os.Getenv(\"PORT\")
fmt.Println(\"Port:\", port) // \"\" si PORT no està definida
}El problema és que no pots distingir entre “la variable existeix i el seu valor és buit” i “la variable no existeix”. En la majoria de casos no importa, però quan necessites saber si algú ha configurat alguna cosa explícitament, necessites os.LookupEnv:
func main() {
port, exists := os.LookupEnv(\"PORT\")
if !exists {
port = \"8080\"
}
fmt.Println(\"Port:\", port)
}os.LookupEnv retorna el valor i un booleà que indica si la variable existeix. Això et permet implementar valors per defecte de forma explícita: si la variable no hi és, fas servir el default. Si hi és però buida, respectes aquest valor buit.
Per a configuracions simples (un script, una eina CLI petita), aquestes dues funcions són tot el que necessites. No cal res més. Però en el moment que el teu servei té més de cinc o sis variables de configuració, llegir-les una a una a main() es converteix en un bloc de codi repetitiu i difícil de mantenir.
El patró Config struct: configuració centralitzada
El pas natural és agrupar tota la configuració en un struct i crear una funció que el construeix a partir de l’entorn. Aquest patró és tan comú en Go que pràcticament és un estàndard no escrit.
// 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
}I al teu main.go:
func main() {
cfg, err := config.Load()
if err != nil {
log.Fatalf(\"configuration error: %v\", err)
}
// cfg es passa com a dependència a handlers, services, etc.
log.Printf(\"Starting server on :%d [%s]\", cfg.Port, cfg.Environment)
}Fixa’t en diverses coses:
Load()retorna un error. No falog.Fatalinternament. La decisió de què fer amb un error de configuració la prenmain(), no el paquet de config.- Les variables requerides fallen explícitament. Si
DATABASE_URLno hi és, el servei no arrenca. No es queda executant-se amb una cadena buida esperant que alguna cosa exploti. - Els valors per defecte són visibles. No estan en un fitxer YAML separat ni en una constant en un altre paquet. Estan allà, al costat de la lectura, on els pots veure.
- El tipus és correcte.
Portés unint, no unstring. La conversió passa una vegada, en carregar la config. La resta del codi treballa amb tipus natius.
Aquest patró és el que faig servir com a base. Per a un servei amb 10-15 variables de configuració, funciona perfectament sense dependències externes. Si t’interessa com s’integra això en l’estructura general d’un projecte, revisa l’article sobre estructura de projecte.
Valors per defecte i validació
Els valors per defecte haurien de seguir un principi simple: el default fa que funcioni en local sense configurar res. El port és 8080, el log level és debug, l’entorn és development. Si un desenvolupador clona el repo i fa go run ., hauria d’arrencar sense haver de crear fitxers ni exportar variables.
Però no tot pot tenir un default. La URL de la base de dades no hauria de tenir un valor per defecte perquè això significa que algú pot arrencar el servei i accidentalment connectar a una base de dades que no és la seva. Els secrets tampoc. Les variables crítiques han de ser requerides, i el servei ha de fallar de forma sorollosa si falten.
Per a validacions més complexes, afegeix un mètode Validate() al 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
}I a Load():
func Load() (*Config, error) {
cfg := &Config{
// ... carregar valors ...
}
if err := cfg.Validate(); err != nil {
return nil, fmt.Errorf(\"config validation failed: %w\", err)
}
return cfg, nil
}La validació passa en el moment de la càrrega, no quan algú fa servir el valor. Si PORT és 99999, el programa ni tan sols intenta fer ListenAndServe. Falla en arrencar, amb un missatge clar de què està malament i què esperava.
godotenv per al desenvolupament local
En producció, les variables d’entorn les injecta l’orquestrador (Docker, Kubernetes, la teva plataforma de cloud). En local, exportar variables manualment cada vegada que obres un terminal és tediós. Per això existeix godotenv.
go get github.com/joho/godotenvCrea un fitxer .env a l’arrel del projecte:
PORT=3000
DATABASE_URL=postgres://postgres:postgres@localhost:5432/myapp?sslmode=disable
LOG_LEVEL=debug
ENVIRONMENT=developmentI carrega el fitxer abans de llegir la configuració:
package main
import (
\"log\"
\"github.com/joho/godotenv\"
\"el-meu-projecte/internal/config\"
)
func main() {
// En local, carrega .env. En producció, el fitxer no existeix i no passa res.
_ = godotenv.Load()
cfg, err := config.Load()
if err != nil {
log.Fatalf(\"configuration error: %v\", err)
}
log.Printf(\"Starting server on :%d\", cfg.Port)
}Detalls importants:
- Ignora l’error de
godotenv.Load(). Si el fitxer no existeix (com en producció), simplement no fa res. No vols que el teu servei falli en producció perquè falta un.env. .envno sobreescriu variables existents. Si una variable ja està definida en l’entorn,godotenv.Load()la respecta. Això és correcte: l’entorn del sistema té prioritat..envva a.gitignore. Sempre. Sense excepcions. El fitxer.envconté URLs de base de dades locals, tokens de desenvolupament i configuració específica de cada desenvolupador. No pertany al repositori.
Si vols un fitxer d’exemple que sí vagi al repo, crea un .env.example amb valors ficticis:
PORT=8080
DATABASE_URL=postgres://user:password@localhost:5432/dbname?sslmode=disable
LOG_LEVEL=info
ENVIRONMENT=developmentAixò documenta quines variables necessita el projecte sense exposar valors reals.
envconfig i cleanenv: configuració basada en structs
Quan el teu servei té 20 o més variables de configuració, les funcions helper getEnv, getEnvInt, getEnvRequired es tornen repetitives. És el moment de fer servir una llibreria que mapegi variables d’entorn directament a un struct fent servir tags.
kelseyhightower/envconfig
La llibreria clàssica. Simple, madura, amb poques dependències:
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
}El primer argument de Process és un prefix. Si passes \"APP\", busca APP_PORT, APP_DATABASE_URL, etc. Passar cadena buida busca les variables sense prefix. El prefix és útil quan executes diversos serveis en el mateix entorn i vols evitar col·lisions de noms.
envconfig suporta tipus nativament: int, bool, float64, time.Duration, []string (separats per comes), i tipus que implementin encoding.TextUnmarshaler. Això cobreix pràcticament tot.
ilyakaznacheev/cleanenv
Una alternativa més moderna amb suport per a múltiples fonts (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 a més pot llegir d’un fitxer de configuració i combinar valors amb variables d’entorn, cosa que pot ser útil si tens defaults complexos que no caben en un tag.
La meva recomanació: si només llegeixes variables d’entorn, envconfig és suficient i més lleuger. Si necessites combinar fitxers de configuració amb variables d’entorn, cleanenv val la pena. Si tens menys de 10 variables, les funcions helper manuals són perfectament vàlides i t’estalvies una dependència.
Separar secrets de la configuració
Aquest és un error que veig constantment: tractar els secrets igual que la resta de la configuració. La URL de la base de dades, les API keys, els tokens JWT… tot barrejat en les mateixes variables d’entorn, amb el mateix nivell d’accés, en el mateix .env.
En local, això funciona. En producció, és un problema de seguretat. Els secrets necessiten un tractament diferent:
- Rotació. Una API key hauria de poder rotar-se sense redesplegar. Una variable d’entorn estàndard requereix reiniciar el procés.
- Auditoria. Necessites saber qui ha accedit a un secret i quan. Les variables d’entorn no deixen rastre.
- Control d’accés. No tots els desenvolupadors haurien de poder veure els secrets de producció. Si estan en un
.envcompartit o en les variables del pipeline de CI, qualsevol amb accés al repo els veu.
La solució no és complicada. Separa els secrets de la resta de la configuració a nivell de codi:
type Config struct {
Port int
LogLevel string
Environment string
}
type Secrets struct {
DatabaseURL string
JWTSecret string
StripeAPIKey string
}En local, tots dos poden venir del .env. Però en producció, Config ve de variables d’entorn normals i Secrets ve d’un gestor de secrets: AWS Secrets Manager, HashiCorp Vault, GCP Secret Manager, Azure Key Vault, o el sistema de secrets de Kubernetes.
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
}
// En producció, llegir d'un gestor de secrets
return loadFromSecretsManager()
}Aquesta separació t’obliga a pensar en què és un secret i què no. Si PORT=8080 es filtra, no passa res. Si DATABASE_URL=postgres://admin:supersecret@prod-db:5432/app es filtra, tens un problema. Tractar-los de forma diferent en el codi reflecteix que són diferents en la realitat.
Configuració per entorn: dev, staging, producció
La configuració per entorn no hauria de resoldre’s amb fitxers de configuració diferents per a cada entorn (config.dev.yaml, config.staging.yaml, config.prod.yaml). Aquest patró és fràgil: els fitxers es desincronitzen, algú afegeix una variable al fitxer de desenvolupament i s’oblida d’afegir-la al de producció, i l’error no apareix fins al desplegament.
La forma idiomàtica en Go (i en els Twelve-Factor Apps en general) és que la configuració ve de l’entorn, i l’entorn la injecta qui desplega:
- En local:
.envambgodotenv. - En Docker: variables a
docker-compose.ymlo--env-file. - En Kubernetes: ConfigMaps i Secrets muntats com a variables d’entorn.
- En cloud: variables d’entorn del servei (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\"Si vols veure com s’integra això en un contenidor Docker complet, ho cobreixo a l’article sobre dockeritzar API Go.
El punt clau: el teu codi Go no sap ni li importa d’on vénen les variables. Simplement crida os.Getenv o fa servir el struct de configuració. El mecanisme d’injecció és responsabilitat de l’entorn d’execució, no de l’aplicació.
Si necessites comportaments diferents per entorn (per exemple, desactivar rate limiting en desenvolupament o fer servir un logger estructurat només en producció), fes-ho explícit:
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,
}))
}L’entorn determina el comportament, però la lògica és transparent. No hi ha perfils ocults ni activacions automàtiques. Si vols saber què passa en producció, llegeixes l’if i ho saps.
Testing amb diferents configuracions
Provar codi que depèn de la configuració és senzill si has seguit el patró del struct. En lloc de modificar variables d’entorn als tests (que és fràgil i causa race conditions si executes tests en paral·lel), passes directament el struct amb els valors que vulguis:
func TestServiceWithCustomConfig(t *testing.T) {
cfg := &config.Config{
Port: 9090,
LogLevel: \"debug\",
Environment: \"test\",
}
svc := service.New(cfg)
// ... assercions ...
}Si algun test necessita modificar variables d’entorn (per exemple, per provar la pròpia funció Load()), fes servir t.Setenv, disponible des de 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 és important perquè restaura el valor original de la variable quan el test acaba. No contamina l’entorn per a altres tests. I si intentes fer-lo servir en un test que s’executa amb t.Parallel(), Go falla en compilació. Això és correcte: modificar variables d’entorn globals des de tests paral·lels és una recepta per a flaky tests.
Per provar la validació:
func TestLoadConfigMissingRequired(t *testing.T) {
// No configurem 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\")
}
}Aquests tests són ràpids, no necessiten infraestructura externa, i validen que la teva configuració falla de la forma correcta. Per saber més sobre patrons de testing en Go, mira l’article sobre API REST amb Go, on s’inclouen tests d’integració amb la configuració injectada.
Errors comuns: valors hardcodejats, validació absent
Aquests són els errors de configuració que més he vist en projectes Go en producció.
Hardcodejar valors que haurien de ser configurables
// Malament
db, err := sql.Open(\"postgres\", \"postgres://localhost:5432/mydb\")
// Bé
db, err := sql.Open(\"postgres\", cfg.DatabaseURL)Si hi ha un string literal que canvia entre entorns, hauria de ser una variable de configuració. Punt. Els candidats habituals: URLs de serveis externs, timeouts, mides de pool, feature flags, claus d’API (que a més haurien de ser secrets).
No validar en arrencar
// Malament: falla a les 3 AM quan algú fa una petició
func (h *Handler) CreateOrder(w http.ResponseWriter, r *http.Request) {
apiKey := os.Getenv(\"STRIPE_API_KEY\") // pot estar buida
client := stripe.NewClient(apiKey) // falla aquí, silenciosament o no
// ...
}
// Bé: falla en arrencar
func main() {
cfg, err := config.Load() // valida que STRIPE_API_KEY existeix
if err != nil {
log.Fatalf(\"config: %v\", err)
}
stripeClient := stripe.NewClient(cfg.StripeAPIKey)
handler := NewHandler(stripeClient)
}Tota la configuració es llegeix i valida una vegada, en arrencar. Si alguna cosa falta, el procés ni tan sols comença. És millor un error clar al log del desplegament que un error críptic a les tres de la matinada.
Llegir variables d’entorn fora de la capa de configuració
// Malament: os.Getenv repartit per tot el codi
func (s *Service) DoSomething() {
if os.Getenv(\"ENVIRONMENT\") == \"production\" {
// ...
}
}
// Bé: la configuració s'injecta com a dependència
func (s *Service) DoSomething() {
if s.cfg.Environment == \"production\" {
// ...
}
}Si os.Getenv apareix fora del paquet config, és un senyal d’alarma. Significa que la configuració està dispersa i no pots saber quines variables necessita la teva aplicació sense buscar per tot el codi. Centralitza la lectura en un sol lloc.
No tenir un .env.example
Si un desenvolupador nou clona el projecte i no sap quines variables necessita, perdrà temps. Un .env.example amb totes les variables documentades (i amb valors ficticis per als secrets) és la documentació més útil que pots tenir. Es manté actualitzada perquè falla si no ho és.
La configuració hauria de ser la part més avorrida del teu servei
Després d’haver muntat la configuració en bastants serveis Go, el patró que millor funciona és sempre el mateix: un struct que defineix tot el que necessita el servei, càrrega des de variables d’entorn en arrencar, validació abans de continuar, i injecció com a dependència a la resta del codi. Secrets separats de la configuració ordinària, .env només en local amb godotenv, i l’entorn d’execució (Docker, Kubernetes, cloud) injectant els valors en producció.
No hi ha màgia, no hi ha autoconfiguració, no hi ha perfils ocults. Llegeixes de l’entorn, valores, i arrenques. Si alguna cosa falta, el servei no arrenca i t’ho diu clarament. Això és tot.
Si la gestió de configuració del teu servei et sembla emocionant, probablement l’estàs complicant més del necessari. La configuració hauria de ser invisible: funciona o falla ràpid en arrencar. Res més.


