Configuración con variables de entorno en Go

Las variables de entorno son la forma habitual de configurar un programa que corre en un contenedor o en un servidor: no hay archivo que montar ni secretos en el repositorio. Go las lee con dos funciones, y la diferencia entre ellas es más importante de lo que parece.

Getenv o LookupEnv

os.Setenv("VACIA", "")

fmt.Printf("Getenv(VACIA)      = %q\n", os.Getenv("VACIA"))
fmt.Printf("Getenv(NO_EXISTE)  = %q\n", os.Getenv("NO_EXISTE"))

v, hay := os.LookupEnv("VACIA")
fmt.Printf("LookupEnv(VACIA)     = %q, %v\n", v, hay)
v, hay = os.LookupEnv("NO_EXISTE")
fmt.Printf("LookupEnv(NO_EXISTE) = %q, %v\n", v, hay)
salidaGetenv(VACIA)      = ""
Getenv(NO_EXISTE)  = ""
LookupEnv(VACIA)     = "", true
LookupEnv(NO_EXISTE) = "", false

Ahí está todo. Getenv devuelve la cadena vacía en los dos casos, así que no puedes saber si alguien puso APP_DEPURAR= a propósito o si simplemente no está. LookupEnv te lo dice con el segundo valor.

¿Importa? Importa cuando el valor por omisión no es el vacío. Si APP_HOST por omisión es localhost y alguien lo define vacío queriendo decir «escucha en todas las interfaces», con Getenv le vas a poner localhost igualmente.

Un ayudante por cada tipo

Todo lo que llega del entorno es texto. Estas cuatro funciones son prácticamente todo lo que se necesita:

func texto(clave, porOmision string) string {
	if v, hay := os.LookupEnv(clave); hay {
		return v
	}
	return porOmision
}

func entero(clave string, porOmision int) (int, error) {
	v, hay := os.LookupEnv(clave)
	if !hay || v == "" {
		return porOmision, nil
	}
	n, err := strconv.Atoi(v)
	if err != nil {
		return 0, fmt.Errorf("%s: %q no es un número entero", clave, v)
	}
	return n, nil
}

func booleano(clave string, porOmision bool) (bool, error) {
	v, hay := os.LookupEnv(clave)
	if !hay || v == "" {
		return porOmision, nil
	}
	b, err := strconv.ParseBool(v)
	if err != nil {
		return false, fmt.Errorf("%s: %q no es un booleano", clave, v)
	}
	return b, nil
}

func duracion(clave string, porOmision time.Duration) (time.Duration, error) {
	v, hay := os.LookupEnv(clave)
	if !hay || v == "" {
		return porOmision, nil
	}
	d, err := time.ParseDuration(v)
	if err != nil {
		return 0, fmt.Errorf("%s: %q no es una duración", clave, v)
	}
	return d, nil
}
strconv.ParseBool acepta más de lo que crees: 1, t, T, TRUE, true, True y sus equivalentes para el falso. Lo que no acepta es yes, on ni sí. Y time.ParseDuration entiende 300ms, 1.5h, 2h45m: mucho más legible que un número suelto de segundos del que nadie recuerda la unidad.

La configuración entera, de una vez

La idea es cargarla al arrancar, fallar ahí mismo si algo está mal, y no volver a tocar el entorno en todo el programa:

type Config struct {
	Puerto  int
	Host    string
	Depurar bool
	Espera  time.Duration
	Clave   string
}

func cargar() (Config, error) {
	var c Config
	var err error
	c.Host = texto("APP_HOST", "localhost")
	if c.Puerto, err = entero("APP_PUERTO", 8080); err != nil {
		return c, err
	}
	if c.Depurar, err = booleano("APP_DEPURAR", false); err != nil {
		return c, err
	}
	if c.Espera, err = duracion("APP_ESPERA", 5*time.Second); err != nil {
		return c, err
	}
	c.Clave = os.Getenv("APP_CLAVE")
	if c.Clave == "" {
		return c, fmt.Errorf("APP_CLAVE es obligatoria y no está definida")
	}
	return c, nil
}
APP_PUERTO=9000 APP_DEPURAR=true APP_ESPERA=1500ms APP_CLAVE=s3cr3t go run .
salida{Puerto:9000 Host:localhost Depurar:true Espera:1.5s Clave:s3cr3t}

Sin nada definido salvo la clave, salen los valores por omisión:

salida{Puerto:8080 Host:localhost Depurar:false Espera:5s Clave:x}

Y cuando algo está mal, el programa no arranca a medias:

salidaerror de configuración: APP_CLAVE es obligatoria y no está definida
exit status 1
salidaerror de configuración: APP_PUERTO: "ocho" no es un número entero
exit status 1
Fallar al arrancar es la gracia de todo esto. Un puerto mal escrito tiene que reventar en el primer segundo, con el nombre de la variable y el valor que llegó, no media hora después cuando alguien intente conectarse. Fíjate en que los mensajes dicen qué variable y qué valor: es lo que va a leer alguien a las tres de la mañana.

Ver qué hay definido

for _, e := range os.Environ() {
	clave, valor, _ := strings.Cut(e, "=")
	if strings.HasPrefix(clave, "APP_") {
		if strings.Contains(clave, "CLAVE") {
			valor = "********"
		}
		fmt.Printf("  %-12s = %s\n", clave, valor)
	}
}
salida  APP_DEPURAR  = true
  APP_CLAVE    = ********
  APP_PUERTO   = 9000
  APP_ESPERA   = 1500ms

os.Environ() devuelve un slice de cadenas con la forma CLAVE=valor, y strings.Cut las parte por el primer =, que es justo lo que hace falta porque el valor puede contener más.

Que los secretos no se escapen al imprimir

Arriba enmascaré la clave a mano, pero es fácil olvidarse en el siguiente fmt.Printf que alguien añada. La forma de que no se olvide nunca es darle un tipo propio al secreto:

type Secreto string

func (s Secreto) String() string {
	if s == "" {
		return "(vacío)"
	}
	return "********"
}

type Config struct {
	Host  string
	Clave Secreto
}
c := Config{Host: "localhost", Clave: "s3cr3t"}
fmt.Printf("%+v\n", c)
fmt.Println("y el valor real sigue ahí:", len(string(c.Clave)), "caracteres")
salida{Host:localhost Clave:********}
y el valor real sigue ahí: 6 caracteres

Ahora la contraseña no aparece ni en los registros, ni en un volcado de la configuración, ni en un mensaje de error, porque todo eso pasa por fmt. Para usarla de verdad hay que convertirla explícitamente con string(c.Clave), que es un gesto lo bastante visible como para que se note en una revisión de código.

En las pruebas

func TestConfig(t *testing.T) {
	t.Setenv("APP_PUERTO", "9999")   // se deshace solo al acabar la prueba
	c, err := cargar()
	...
}

t.Setenv es mejor que os.Setenv en una prueba: restaura el valor anterior al terminar. A cambio, el paquete testing no deja usarlo en pruebas marcadas con t.Parallel(), porque el entorno lo comparte todo el proceso.

Lo esencial

Para Usa
Leer con valor por omisión os.LookupEnv, no os.Getenv
Convertir a número strconv.Atoi
Convertir a booleano strconv.ParseBool
Convertir a tiempo time.ParseDuration
Recorrer todo el entorno os.Environ + strings.Cut
Que no se filtre un secreto Un tipo propio con String()
Cambiarlo en una prueba t.Setenv

Todo el código se compiló y ejecutó con Go 1.25 antes de publicarla; las salidas están copiadas de esa ejecución. Documentación oficial: os.LookupEnv y paquete strconv.