Variables de entorno y configuración en Go para aplicaciones backend

Cómo leer variables de entorno en Go, configurar por entornos, validar y separar secretos. Configuración explícita y sin magia.

Cover for Variables de entorno y configuración en Go para aplicaciones backend

La configuración en Go es aburrida. Y eso es exactamente lo que quieres.

No hay autoconfiguración mágica. No hay un framework que escanee anotaciones para inyectar valores. No hay un application.yml con perfiles que se resuelven en cascada según el entorno, la fase lunar y el humor del servidor de CI. En Go, lees una variable de entorno, la asignas a un campo y sigues con tu vida. Si falta, el programa falla al arrancar, no a las tres de la madrugada cuando un usuario hace la petición que toca ese código por primera vez.

Esa simplicidad tiene un coste: tienes que montar la fontanería tú. Pero ese coste se paga una vez, al inicio del proyecto, y después te olvidas. Lo que viene a continuación es un patrón que uso en todos mis servicios Go y que cubre el 95% de los casos sin añadir complejidad innecesaria.


os.Getenv y os.LookupEnv: lo básico

Go tiene dos funciones en la librería estándar para leer variables de entorno. La diferencia entre ellas es sutil pero importante.

os.Getenv devuelve el valor de la variable o una cadena vacía si no existe:

package main

import (
    "fmt"
    "os"
)

func main() {
    port := os.Getenv("PORT")
    fmt.Println("Puerto:", port) // "" si PORT no está definida
}

El problema es que no puedes distinguir entre “la variable existe y su valor es vacío” y “la variable no existe”. En la mayoría de casos no importa, pero cuando necesitas saber si alguien configuró algo explícitamente, necesitas os.LookupEnv:

func main() {
    port, exists := os.LookupEnv("PORT")
    if !exists {
        port = "8080"
    }
    fmt.Println("Puerto:", port)
}

os.LookupEnv devuelve el valor y un booleano que indica si la variable existe. Esto te permite implementar valores por defecto de forma explícita: si la variable no está, usas el default. Si está pero vacía, respetas ese valor vacío.

Para configuraciones simples (un script, una herramienta CLI pequeña), estas dos funciones son todo lo que necesitas. No hace falta más. Pero en cuanto tu servicio tiene más de cinco o seis variables de configuración, leerlas una a una en main() se convierte en un bloque de código repetitivo y difícil de mantener.


El patrón Config struct: configuración centralizada

El paso natural es agrupar toda la configuración en un struct y crear una función que lo construye a partir del entorno. Este patrón es tan común en Go que prácticamente es un estándar no escrito.

// 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
}

Y en tu main.go:

func main() {
    cfg, err := config.Load()
    if err != nil {
        log.Fatalf("configuration error: %v", err)
    }

    // cfg se pasa como dependencia a handlers, services, etc.
    log.Printf("Starting server on :%d [%s]", cfg.Port, cfg.Environment)
}

Fíjate en varias cosas:

  1. Load() devuelve un error. No hace log.Fatal internamente. La decisión de qué hacer con un error de configuración la toma main(), no el paquete de config.
  2. Las variables requeridas fallan explícitamente. Si DATABASE_URL no está, el servicio no arranca. No se queda corriendo con una cadena vacía esperando a que algo explote.
  3. Los valores por defecto son visibles. No están en un fichero YAML separado ni en una constante en otro paquete. Están ahí, al lado de la lectura, donde los puedes ver.
  4. El tipo es correcto. Port es un int, no un string. La conversión ocurre una vez, al cargar la config. El resto del código trabaja con tipos nativos.

Este patrón es el que uso como base. Para un servicio con 10-15 variables de configuración, funciona perfectamente sin dependencias externas. Si te interesa cómo se integra esto en la estructura general de un proyecto, revisa el artículo sobre estructura de proyecto.


Valores por defecto y validación

Los valores por defecto deberían seguir un principio simple: el default hace que funcione en local sin configurar nada. El puerto es 8080, el log level es debug, el entorno es development. Si un desarrollador clona el repo y hace go run ., debería arrancar sin tener que crear ficheros ni exportar variables.

Pero no todo puede tener un default. La URL de la base de datos no debería tener un valor por defecto porque eso significa que alguien puede arrancar el servicio y accidentalmente conectar a una base de datos que no es la suya. Los secretos tampoco. Las variables críticas deben ser requeridas, y el servicio debe fallar de forma ruidosa si faltan.

Para validaciones más complejas, añade un método 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
}

Y en Load():

func Load() (*Config, error) {
    cfg := &Config{
        // ... cargar valores ...
    }

    if err := cfg.Validate(); err != nil {
        return nil, fmt.Errorf("config validation failed: %w", err)
    }

    return cfg, nil
}

La validación ocurre en el momento de la carga, no cuando alguien usa el valor. Si PORT es 99999, el programa no llega ni a intentar hacer ListenAndServe. Falla al arrancar, con un mensaje claro de qué está mal y qué esperaba.


godotenv para desarrollo local

En producción, las variables de entorno las inyecta el orquestador (Docker, Kubernetes, tu plataforma de cloud). En local, exportar variables a mano cada vez que abres un terminal es tedioso. Para eso existe godotenv.

go get github.com/joho/godotenv

Crea un archivo .env en la raíz del proyecto:

PORT=3000
DATABASE_URL=postgres://postgres:postgres@localhost:5432/myapp?sslmode=disable
LOG_LEVEL=debug
ENVIRONMENT=development

Y carga el fichero antes de leer la configuración:

package main

import (
    "log"

    "github.com/joho/godotenv"
    "mi-proyecto/internal/config"
)

func main() {
    // En local, carga .env. En producción, el fichero no existe y no pasa nada.
    _ = godotenv.Load()

    cfg, err := config.Load()
    if err != nil {
        log.Fatalf("configuration error: %v", err)
    }

    log.Printf("Starting server on :%d", cfg.Port)
}

Detalles importantes:

  • Ignora el error de godotenv.Load(). Si el fichero no existe (como en producción), simplemente no hace nada. No quieres que tu servicio falle en producción porque falta un .env.
  • .env no sobreescribe variables existentes. Si una variable ya está definida en el entorno, godotenv.Load() la respeta. Esto es correcto: el entorno del sistema tiene prioridad.
  • .env va en .gitignore. Siempre. Sin excepciones. El fichero .env contiene URLs de base de datos locales, tokens de desarrollo y configuración específica de cada desarrollador. No pertenece al repositorio.

Si quieres un fichero de ejemplo que sí vaya al repo, crea un .env.example con valores ficticios:

PORT=8080
DATABASE_URL=postgres://user:password@localhost:5432/dbname?sslmode=disable
LOG_LEVEL=info
ENVIRONMENT=development

Esto documenta qué variables necesita el proyecto sin exponer valores reales.


envconfig y cleanenv: configuración basada en structs

Cuando tu servicio tiene 20 o más variables de configuración, las funciones helper getEnv, getEnvInt, getEnvRequired se vuelven repetitivas. Es el momento de usar una librería que mapee variables de entorno directamente a un struct usando tags.

kelseyhightower/envconfig

La librería clásica. Simple, madura, con pocas dependencias:

go get github.com/kelseyhightower/envconfig
package 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 argumento de Process es un prefijo. Si pasas "APP", busca APP_PORT, APP_DATABASE_URL, etc. Pasar cadena vacía busca las variables sin prefijo. El prefijo es útil cuando corres varios servicios en el mismo entorno y quieres evitar colisiones de nombres.

envconfig soporta tipos nativamente: int, bool, float64, time.Duration, []string (separados por comas), y tipos que implementen encoding.TextUnmarshaler. Eso cubre prácticamente todo.

ilyakaznacheev/cleanenv

Una alternativa más moderna con soporte para múltiples fuentes (YAML, TOML, ENV):

go get github.com/ilyakaznacheev/cleanenv
package 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 además puede leer de un fichero de configuración y combinar valores con variables de entorno, lo que puede ser útil si tienes defaults complejos que no caben en un tag.

Mi recomendación: si solo lees variables de entorno, envconfig es suficiente y más ligero. Si necesitas combinar ficheros de configuración con variables de entorno, cleanenv merece la pena. Si tienes menos de 10 variables, las funciones helper manuales son perfectamente válidas y te ahorras una dependencia.


Separar secretos de la configuración

Este es un error que veo constantemente: tratar los secretos igual que el resto de la configuración. La URL de la base de datos, las API keys, los tokens JWT… todo mezclado en las mismas variables de entorno, con el mismo nivel de acceso, en el mismo .env.

En local, esto funciona. En producción, es un problema de seguridad. Los secretos necesitan un tratamiento diferente:

  1. Rotación. Un API key debería poder rotarse sin redesplegar. Una variable de entorno estándar requiere reiniciar el proceso.
  2. Auditoría. Necesitas saber quién accedió a un secreto y cuándo. Las variables de entorno no dejan rastro.
  3. Control de acceso. No todos los desarrolladores deberían poder ver los secretos de producción. Si están en un .env compartido o en las variables del pipeline de CI, cualquiera con acceso al repo las ve.

La solución no es complicada. Separa los secretos del resto de la configuración a nivel de código:

type Config struct {
    Port        int
    LogLevel    string
    Environment string
}

type Secrets struct {
    DatabaseURL    string
    JWTSecret      string
    StripeAPIKey   string
}

En local, ambos pueden venir del .env. Pero en producción, Config viene de variables de entorno normales y Secrets viene de un gestor de secretos: AWS Secrets Manager, HashiCorp Vault, GCP Secret Manager, Azure Key Vault, o el sistema de secretos 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ón, leer de un gestor de secretos
    return loadFromSecretsManager()
}

Esta separación te obliga a pensar en qué es un secreto y qué no. Si PORT=8080 se filtra, no pasa nada. Si DATABASE_URL=postgres://admin:supersecret@prod-db:5432/app se filtra, tienes un problema. Tratarlos de forma diferente en el código refleja que son diferentes en la realidad.


Configuración por entorno: dev, staging, producción

La configuración por entorno no debería resolverse con ficheros de configuración diferentes para cada entorno (config.dev.yaml, config.staging.yaml, config.prod.yaml). Ese patrón es frágil: los ficheros se desincronizan, alguien añade una variable al fichero de desarrollo y se olvida de añadirla al de producción, y el error no aparece hasta el despliegue.

La forma idiomática en Go (y en los Twelve-Factor Apps en general) es que la configuración viene del entorno, y el entorno la inyecta quien despliega:

  • En local: .env con godotenv.
  • En Docker: variables en docker-compose.yml o --env-file.
  • En Kubernetes: ConfigMaps y Secrets montados como variables de entorno.
  • En cloud: variables de entorno del servicio (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 quieres ver cómo se integra esto en un contenedor Docker completo, lo cubro en el artículo sobre dockerizar API Go.

El punto clave: tu código Go no sabe ni le importa de dónde vienen las variables. Solo llama a os.Getenv o usa el struct de configuración. El mecanismo de inyección es responsabilidad del entorno de ejecución, no de la aplicación.

Si necesitas comportamientos diferentes por entorno (por ejemplo, desactivar rate limiting en desarrollo o usar un logger estructurado solo en producción), hazlo explícito:

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,
    }))
}

El entorno determina el comportamiento, pero la lógica es transparente. No hay perfiles ocultos ni activaciones automáticas. Si quieres saber qué pasa en producción, lees el if y lo sabes.


Testing con diferentes configuraciones

Probar código que depende de la configuración es sencillo si has seguido el patrón del struct. En lugar de modificar variables de entorno en los tests (que es frágil y causa race conditions si corres tests en paralelo), pasas directamente el struct con los valores que quieras:

func TestServiceWithCustomConfig(t *testing.T) {
    cfg := &config.Config{
        Port:        9090,
        LogLevel:    "debug",
        Environment: "test",
    }

    svc := service.New(cfg)
    // ... aserciones ...
}

Si algún test necesita modificar variables de entorno (por ejemplo, para probar la propia función Load()), usa t.Setenv, que está disponible desde 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 es importante porque restaura el valor original de la variable cuando el test termina. No contamina el entorno para otros tests. Y si intentas usarlo en un test que corre con t.Parallel(), Go peta en compilación. Esto es correcto: modificar variables de entorno globales desde tests paralelos es una receta para flaky tests.

Para probar la validación:

func TestLoadConfigMissingRequired(t *testing.T) {
    // No seteamos 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")
    }
}

Estos tests son rápidos, no necesitan infraestructura externa, y validan que tu configuración falla de la forma correcta. Para saber más sobre patrones de testing en Go, mira el artículo sobre API REST con Go, donde se incluyen tests de integración con la configuración inyectada.


Errores comunes: valores hardcodeados, validación ausente

Estos son los errores de configuración que más he visto en proyectos Go en producción.

Hardcodear valores que deberían ser configurables

// Mal
db, err := sql.Open("postgres", "postgres://localhost:5432/mydb")

// Bien
db, err := sql.Open("postgres", cfg.DatabaseURL)

Si hay un string literal que cambia entre entornos, debería ser una variable de configuración. Punto. Los candidatos habituales: URLs de servicios externos, timeouts, tamaños de pool, feature flags, claves de API (que además deberían ser secretos).

No validar al arrancar

// Mal: falla a las 3 AM cuando alguien hace una petición
func (h *Handler) CreateOrder(w http.ResponseWriter, r *http.Request) {
    apiKey := os.Getenv("STRIPE_API_KEY") // puede estar vacía
    client := stripe.NewClient(apiKey)     // falla aquí, silenciosamente o no
    // ...
}

// Bien: falla al arrancar
func main() {
    cfg, err := config.Load() // valida que STRIPE_API_KEY existe
    if err != nil {
        log.Fatalf("config: %v", err)
    }
    stripeClient := stripe.NewClient(cfg.StripeAPIKey)
    handler := NewHandler(stripeClient)
}

Toda la configuración se lee y valida una vez, al arrancar. Si algo falta, el proceso ni siquiera empieza. Es mejor un error claro en el log del despliegue que un error críptico a las tres de la madrugada.

Leer variables de entorno fuera de la capa de configuración

// Mal: os.Getenv repartido por todo el código
func (s *Service) DoSomething() {
    if os.Getenv("ENVIRONMENT") == "production" {
        // ...
    }
}

// Bien: la configuración se inyecta como dependencia
func (s *Service) DoSomething() {
    if s.cfg.Environment == "production" {
        // ...
    }
}

Si os.Getenv aparece fuera del paquete config, es una señal de alarma. Significa que la configuración está dispersa y no puedes saber qué variables necesita tu aplicación sin buscar por todo el código. Centraliza la lectura en un solo sitio.

No tener un .env.example

Si un desarrollador nuevo clona el proyecto y no sabe qué variables necesita, va a perder tiempo. Un .env.example con todas las variables documentadas (y con valores ficticios para los secretos) es la documentación más útil que puedes tener. Se mantiene actualizada porque falla si no lo está.


La configuración debería ser la parte más aburrida de tu servicio

Después de haber montado la configuración en bastantes servicios Go, el patrón que mejor funciona es siempre el mismo: un struct que define todo lo que necesita el servicio, carga desde variables de entorno al arrancar, validación antes de continuar, e inyección como dependencia al resto del código. Secretos separados de configuración ordinaria, .env solo en local con godotenv, y el entorno de ejecución (Docker, Kubernetes, cloud) inyectando los valores en producción.

No hay magia, no hay autoconfiguración, no hay perfiles ocultos. Lees del entorno, validas, y arrancas. Si algo falta, el servicio no arranca y te lo dice claramente. Eso es todo.

Si la gestión de configuración de tu servicio te parece emocionante, probablemente la estés complicando más de lo necesario. La configuración debería ser invisible: funciona o falla rápido al arrancar. Nada más.

OshyTech

Ingeniería backend y de datos orientada a sistemas escalables, automatización e IA.

Navegación

Copyright 2026 OshyTech. Todos los derechos reservados