Um sistema bem projetado separa código de configuração. O mesmo binário deve funcionar em desenvolvimento com um banco SQLite local, em staging com PostgreSQL compartilhado e em produção com um cluster gerenciado — sem recompilar, sem alterar código. A configuração é o que muda, não o programa.
A metodologia The Twelve-Factor App estabelece que configurações devem vir de variáveis de ambiente. Na prática, sistemas reais combinam múltiplas fontes: arquivos para configurações padrão, variáveis de ambiente para sobrescritas por ambiente, flags de linha de comando para sobrescritas pontuais. O Viper unifica todas essas fontes com precedência bem definida.
Abordagem 1: os.Getenv e os.LookupEnv
Para aplicações simples, a biblioteca padrão é suficiente:
package main
import (
"fmt"
"os"
"strconv"
"time"
)
type Config struct {
Servidor ServidorConfig
Banco BancoConfig
JWT JWTConfig
}
type ServidorConfig struct {
Host string
Porta int
ReadTimeout time.Duration
WriteTimeout time.Duration
}
type BancoConfig struct {
URL string
MaxConexoes int
MaxOciosas int
}
type JWTConfig struct {
Segredo string
Expiracao time.Duration
}
func carregarConfigEnv() (*Config, error) {
porta, err := strconv.Atoi(getEnv("SERVIDOR_PORTA", "8080"))
if err != nil {
return nil, fmt.Errorf("SERVIDOR_PORTA inválida: %w", err)
}
maxConexoes, _ := strconv.Atoi(getEnv("DB_MAX_CONEXOES", "25"))
maxOciosas, _ := strconv.Atoi(getEnv("DB_MAX_OCIOSAS", "10"))
segredo, existe := os.LookupEnv("JWT_SEGREDO")
if !existe || segredo == "" {
return nil, fmt.Errorf("JWT_SEGREDO é obrigatório")
}
dbURL, existe := os.LookupEnv("DATABASE_URL")
if !existe || dbURL == "" {
return nil, fmt.Errorf("DATABASE_URL é obrigatório")
}
return &Config{
Servidor: ServidorConfig{
Host: getEnv("SERVIDOR_HOST", "0.0.0.0"),
Porta: porta,
ReadTimeout: getDuration("SERVIDOR_READ_TIMEOUT", 5*time.Second),
WriteTimeout: getDuration("SERVIDOR_WRITE_TIMEOUT", 10*time.Second),
},
Banco: BancoConfig{
URL: dbURL,
MaxConexoes: maxConexoes,
MaxOciosas: maxOciosas,
},
JWT: JWTConfig{
Segredo: segredo,
Expiracao: getDuration("JWT_EXPIRACAO", 24*time.Hour),
},
}, nil
}
func getEnv(chave, padrao string) string {
if v := os.Getenv(chave); v != "" {
return v
}
return padrao
}
func getDuration(chave string, padrao time.Duration) time.Duration {
v := os.Getenv(chave)
if v == "" {
return padrao
}
d, err := time.ParseDuration(v)
if err != nil {
return padrao
}
return d
}
Abordagem 2: arquivo .env com godotenv
Em desenvolvimento, variáveis de ambiente são frequentemente gerenciadas via arquivo .env:
go get github.com/joho/godotenv
# .env — nunca commitar em repositórios públicos
APP_ENV=desenvolvimento
SERVIDOR_PORTA=8080
DATABASE_URL=postgres://usuario:senha@localhost:5432/app?sslmode=disable
JWT_SEGREDO=meu-segredo-desenvolvimento-nao-usar-em-producao
LOG_LEVEL=debug
REDIS_URL=redis://localhost:6379
package main
import (
"log"
"os"
"github.com/joho/godotenv"
)
func carregarDotEnv() {
env := os.Getenv("APP_ENV")
// Carrega arquivo específico do ambiente se existir
if env != "" {
godotenv.Load(".env." + env)
}
// Carrega .env padrão (não sobrescreve valores já definidos)
if err := godotenv.Load(); err != nil {
if os.IsNotExist(err) {
log.Println("Arquivo .env não encontrado — usando variáveis do sistema")
return
}
log.Printf("Aviso: erro ao carregar .env: %v", err)
}
}
Arquivos por ambiente:
.env ← padrões de desenvolvimento
.env.staging ← sobrescritas para staging
.env.test ← sobrescritas para testes
.gitignore ← .env listado aqui
Abordagem 3: Viper — configuração completa
O Viper é a solução mais completa para configuração em Go. Suporta arquivos YAML, JSON, TOML e dotenv, variáveis de ambiente, flags do Cobra, valores padrão e watching de arquivo para recarga em tempo real. INI e HCL, que versões antigas aceitavam, não fazem mais parte do pacote: na versão 1.21, os dois falham com decoder not found for this format, a menos que um codec seja registrado à parte.
go get github.com/spf13/viper
Arquivo de configuração
# config/config.yaml
app:
nome: "Minha API"
versao: "1.0.0"
ambiente: desenvolvimento
servidor:
host: "0.0.0.0"
porta: 8080
read_timeout: "5s"
write_timeout: "10s"
idle_timeout: "60s"
banco:
url: "postgres://usuario:senha@localhost:5432/app"
max_conexoes: 25
max_ociosas: 10
max_vida: "5m"
max_ocioso: "2m"
cache:
url: "redis://localhost:6379"
ttl: "1h"
jwt:
expiracao: "24h"
log:
nivel: "info"
formato: "json"
Carregando com Viper
package config
import (
"fmt"
"strings"
"time"
"github.com/spf13/pflag"
"github.com/spf13/viper"
)
type Config struct {
App AppConfig
Servidor ServidorConfig
Banco BancoConfig
Cache CacheConfig
JWT JWTConfig
Log LogConfig
}
type AppConfig struct {
Nome string
Versao string
Ambiente string
}
type ServidorConfig struct {
Host string
Porta int
ReadTimeout time.Duration
WriteTimeout time.Duration
IdleTimeout time.Duration
}
type BancoConfig struct {
URL string
MaxConexoes int
MaxOciosas int
MaxVida time.Duration
MaxOcioso time.Duration
}
type CacheConfig struct {
URL string
TTL time.Duration
}
type JWTConfig struct {
Segredo string
Expiracao time.Duration
}
type LogConfig struct {
Nivel string
Formato string
}
// flags pode ser nil; quando vem do Cobra, a flag --port passa a valer
// sobre o ambiente, o arquivo e o padrão
func Carregar(caminhoConfig string, flags *pflag.FlagSet) (*Config, error) {
v := viper.New()
// Valores padrão — base de segurança
v.SetDefault("servidor.host", "0.0.0.0")
v.SetDefault("servidor.porta", 8080)
v.SetDefault("servidor.read_timeout", "5s")
v.SetDefault("servidor.write_timeout", "10s")
v.SetDefault("servidor.idle_timeout", "60s")
v.SetDefault("banco.max_conexoes", 25)
v.SetDefault("banco.max_ociosas", 10)
v.SetDefault("banco.max_vida", "5m")
v.SetDefault("banco.max_ocioso", "2m")
v.SetDefault("cache.ttl", "1h")
v.SetDefault("jwt.expiracao", "24h")
v.SetDefault("log.nivel", "info")
v.SetDefault("log.formato", "json")
// Arquivo de configuração
if caminhoConfig != "" {
v.SetConfigFile(caminhoConfig)
} else {
v.AddConfigPath("./config")
v.AddConfigPath(".")
v.AddConfigPath("$HOME/.minha-app")
v.SetConfigName("config")
v.SetConfigType("yaml")
}
if err := v.ReadInConfig(); err != nil {
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
return nil, fmt.Errorf("ler configuração: %w", err)
}
// Arquivo não encontrado é aceitável — usamos padrões e env vars
}
// Variáveis de ambiente sobrescrevem o arquivo
// APP_SERVIDOR_PORTA sobrescreve servidor.porta
v.SetEnvPrefix("APP")
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
v.AutomaticEnv()
// A flag precisa ser vinculada a esta instância, v. Vinculá-la ao viper
// global, com viper.BindPFlag, não teria efeito: a leitura é feita em v
if flags != nil {
if f := flags.Lookup("port"); f != nil {
if err := v.BindPFlag("servidor.porta", f); err != nil {
return nil, err
}
}
}
return construirConfig(v)
}
// lerDuracao recusa valores inválidos. GetDuration devolveria 0 em silêncio,
// e um MaxVida de 0 no pool de conexões significa "sem limite"
func lerDuracao(v *viper.Viper, chave string) (time.Duration, error) {
d, err := time.ParseDuration(v.GetString(chave))
if err != nil {
return 0, fmt.Errorf("%s inválido: %w", chave, err)
}
return d, nil
}
func construirConfig(v *viper.Viper) (*Config, error) {
duracoes := map[string]time.Duration{}
for _, chave := range []string{
"servidor.read_timeout", "servidor.write_timeout", "servidor.idle_timeout",
"banco.max_vida", "banco.max_ocioso", "cache.ttl", "jwt.expiracao",
} {
d, err := lerDuracao(v, chave)
if err != nil {
return nil, err
}
duracoes[chave] = d
}
cfg := &Config{
App: AppConfig{
Nome: v.GetString("app.nome"),
Versao: v.GetString("app.versao"),
Ambiente: v.GetString("app.ambiente"),
},
Servidor: ServidorConfig{
Host: v.GetString("servidor.host"),
Porta: v.GetInt("servidor.porta"),
ReadTimeout: duracoes["servidor.read_timeout"],
WriteTimeout: duracoes["servidor.write_timeout"],
IdleTimeout: duracoes["servidor.idle_timeout"],
},
Banco: BancoConfig{
URL: v.GetString("banco.url"),
MaxConexoes: v.GetInt("banco.max_conexoes"),
MaxOciosas: v.GetInt("banco.max_ociosas"),
MaxVida: duracoes["banco.max_vida"],
MaxOcioso: duracoes["banco.max_ocioso"],
},
Cache: CacheConfig{
URL: v.GetString("cache.url"),
TTL: duracoes["cache.ttl"],
},
JWT: JWTConfig{
Segredo: v.GetString("jwt.segredo"),
Expiracao: duracoes["jwt.expiracao"],
},
Log: LogConfig{
Nivel: v.GetString("log.nivel"),
Formato: v.GetString("log.formato"),
},
}
return cfg, validarConfig(cfg)
}
func validarConfig(cfg *Config) error {
var erros []string
if cfg.Banco.URL == "" {
erros = append(erros, "banco.url é obrigatório")
}
if cfg.JWT.Segredo == "" {
erros = append(erros, "jwt.segredo é obrigatório")
}
if cfg.Servidor.Porta < 1 || cfg.Servidor.Porta > 65535 {
erros = append(erros, fmt.Sprintf("servidor.porta inválida: %d", cfg.Servidor.Porta))
}
if len(erros) > 0 {
return fmt.Errorf("configuração inválida:\n - %s",
strings.Join(erros, "\n - "))
}
return nil
}
Precedência do Viper
Maior precedência → Menor precedência
1. Set explícito (viper.Set)
2. Flag da linha de comando (via pflag/cobra)
3. Variável de ambiente
4. Arquivo de configuração
5. Valor padrão (viper.SetDefault)
// Demonstração de precedência
v := viper.New()
v.SetDefault("porta", 8080) // 5. padrão
v.SetConfigFile("config.yaml") // 4. arquivo (porta: 9090)
v.ReadInConfig()
v.SetEnvPrefix("APP") // sem o prefixo, AutomaticEnv procuraria PORTA
v.AutomaticEnv() // 3. APP_PORTA=9191
// v.Set("porta", 9292) // 1. mais alta precedência
fmt.Println(v.GetInt("porta")) // 9191 se APP_PORTA=9191, senão 9090
Watching: recarga automática de configuração
// fsnotify.Event vem de github.com/fsnotify/fsnotify. O callback roda em outra
// goroutine: onMudanca precisa publicar a nova config de forma sincronizada
func configurarWatching(v *viper.Viper, onMudanca func(cfg *Config)) {
v.WatchConfig()
v.OnConfigChange(func(e fsnotify.Event) {
fmt.Printf("Configuração alterada: %s\n", e.Name)
cfg, err := construirConfig(v)
if err != nil {
fmt.Printf("Erro na nova configuração: %v\n", err)
return
}
onMudanca(cfg)
})
}
Integração com Cobra
package cmd
import (
"github.com/spf13/cobra"
"minha-api/config"
)
var (
cfgFile string
cfg *config.Config // lida antes de qualquer subcomando
)
var rootCmd = &cobra.Command{
Use: "api",
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
// cmd.Flags() inclui as flags persistentes herdadas da raiz;
// o Carregar vincula a --port à instância do Viper que ele lê
var err error
cfg, err = config.Carregar(cfgFile, cmd.Flags())
return err
},
}
func init() {
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "",
"arquivo de configuração")
rootCmd.PersistentFlags().Int("port", 0,
"porta do servidor (sobrescreve configuração)")
}
Validação de configuração com struct tags
Para projetos que preferem validação declarativa:
package main
import (
"fmt"
"os"
"strconv"
"strings"
"github.com/go-playground/validator/v10"
)
type ConfigValidada struct {
DatabaseURL string `validate:"required,url"`
Porta int `validate:"required,min=1,max=65535"`
JWTSegredo string `validate:"required,min=32"`
Ambiente string `validate:"required,oneof=desenvolvimento staging producao"`
LogNivel string `validate:"required,oneof=debug info warn error"`
}
func carregarEValidar() (*ConfigValidada, error) {
porta, _ := strconv.Atoi(os.Getenv("PORTA"))
if porta == 0 {
porta = 8080
}
cfg := &ConfigValidada{
DatabaseURL: os.Getenv("DATABASE_URL"),
Porta: porta,
JWTSegredo: os.Getenv("JWT_SEGREDO"),
Ambiente: getEnv("APP_ENV", "desenvolvimento"),
LogNivel: getEnv("LOG_LEVEL", "info"),
}
validate := validator.New()
if err := validate.Struct(cfg); err != nil {
erros := err.(validator.ValidationErrors)
mensagens := make([]string, 0, len(erros))
for _, e := range erros {
mensagens = append(mensagens,
fmt.Sprintf(" %s: falhou na validação '%s'", e.Field(), e.Tag()))
}
return nil, fmt.Errorf("configuração inválida:\n%s",
strings.Join(mensagens, "\n"))
}
return cfg, nil
}
Exemplo completo: main.go com configuração
package main
import (
"context"
"fmt"
"log/slog"
"net/http"
"net/url"
"os"
"os/signal"
"syscall"
"time"
"github.com/joho/godotenv"
"minha-api/config"
)
func main() {
// 1. Carrega .env em desenvolvimento
if os.Getenv("APP_ENV") == "" || os.Getenv("APP_ENV") == "desenvolvimento" {
godotenv.Load()
}
// 2. Carrega configuração completa
cfg, err := config.Carregar("", nil)
if err != nil {
fmt.Fprintf(os.Stderr, "Configuração inválida: %v\n", err)
os.Exit(1)
}
// 3. Configura logger
var nivel slog.Level
switch cfg.Log.Nivel {
case "debug":
nivel = slog.LevelDebug
case "warn":
nivel = slog.LevelWarn
case "error":
nivel = slog.LevelError
default:
nivel = slog.LevelInfo
}
handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: nivel,
})
logger := slog.New(handler).With(
"serviço", cfg.App.Nome,
"versao", cfg.App.Versao,
"ambiente", cfg.App.Ambiente,
)
slog.SetDefault(logger)
// 4. Log de inicialização
slog.Info("aplicação iniciada",
"porta", cfg.Servidor.Porta,
"banco", mascararSenha(cfg.Banco.URL),
)
// 5. Inicia servidor
mux := http.NewServeMux()
mux.HandleFunc("GET /saude", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"status":"ok","versao":"%s"}`, cfg.App.Versao)
})
addr := fmt.Sprintf("%s:%d", cfg.Servidor.Host, cfg.Servidor.Porta)
servidor := &http.Server{
Addr: addr,
Handler: mux,
ReadTimeout: cfg.Servidor.ReadTimeout,
WriteTimeout: cfg.Servidor.WriteTimeout,
IdleTimeout: cfg.Servidor.IdleTimeout,
}
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
go func() {
slog.Info("servidor HTTP iniciado", "addr", addr)
if err := servidor.ListenAndServe(); err != http.ErrServerClosed {
slog.Error("erro no servidor", "err", err)
os.Exit(1)
}
}()
<-quit
slog.Info("sinal de encerramento recebido")
ctx, cancelar := context.WithTimeout(context.Background(), 30*time.Second)
defer cancelar()
if err := servidor.Shutdown(ctx); err != nil {
slog.Error("erro no shutdown", "err", err)
os.Exit(1)
}
slog.Info("servidor encerrado com sucesso")
}
func mascararSenha(dsn string) string {
// postgres://usuario:SENHA@host/banco → postgres://usuario:xxxxx@host/banco.
// Cortar a string à mão falha com senha que contém @ e com URL sem
// senha; o Redacted da biblioteca padrão trata os dois casos
u, err := url.Parse(dsn)
if err != nil {
return "(URL inválida)"
}
return u.Redacted()
}
Configuração bem feita deixa o mesmo binário rodar em qualquer ambiente, e as três abordagens do artigo cobrem do caso mais simples ao mais completo: os.LookupEnv para poucas variáveis, godotenv para o .env do desenvolvimento e o Viper para juntar padrões, arquivo, ambiente e flags com uma precedência definida. Os defeitos mais comuns vêm de ligações que não acontecem: a variável de ambiente sem o prefixo esperado, a flag vinculada a uma instância do Viper diferente da que é lida, a duração inválida que vira zero. Por isso vale validar a configuração inteira na subida e falhar com uma mensagem clara, em vez de deixar o serviço rodar com um valor que ninguém escolheu. E qualquer configuração que contenha segredo precisa passar por um filtro antes de chegar ao log.
Fontes e leituras recomendadas
-
Viper — documentação oficial — Referência completa de todos os recursos. https://github.com/spf13/viper
-
godotenv — Carregamento de arquivos .env para Go. https://github.com/joho/godotenv
-
The Twelve-Factor App — Config — Metodologia de configuração por variáveis de ambiente. https://12factor.net/config
-
go-playground/validator — Validação de structs com tags. https://github.com/go-playground/validator
-
envconfig — Alternativa ao Viper focada em variáveis de ambiente. https://github.com/kelseyhightower/envconfig
-
Documentação os.Getenv — Referência da biblioteca padrão. https://pkg.go.dev/os#Getenv
Exercícios
Exercício 1
Num ambiente de testes, alguém define APP_PORTA=9191 e roda a demonstração de precedência sem a linha v.SetEnvPrefix("APP"). O programa imprime 9090, o valor do arquivo. A variável de ambiente não deveria vencer o arquivo?
Ver resposta
✓ Resposta: Venceria, se o Viper estivesse procurando por ela. O AutomaticEnv faz cada Get consultar uma variável de ambiente com o nome da chave em maiúsculas, precedido do prefixo, quando há um. Sem SetEnvPrefix, a chave porta corresponde à variável PORTA, que não existe, e o Viper desce para o arquivo. Num teste, com APP_PORTA=9191 definida, a leitura deu 9090; depois de SetEnvPrefix("APP"), deu 9191. Nas chaves aninhadas há uma segunda condição: servidor.porta vira APP_SERVIDOR.PORTA, com ponto, e só o SetEnvKeyReplacer(strings.NewReplacer(".", "_")) do Carregar a transforma em APP_SERVIDOR_PORTA. Como nenhum dos dois casos gera erro, vale um teste que define a variável com t.Setenv e confere o valor lido.
Exercício 2
Na versão antiga da integração com o Cobra, o init fazia viper.BindPFlag("servidor.porta", ...), e o Carregar lia a configuração com v := viper.New(). O comando api serve --port 7777 subia na 8080. Onde a flag foi parar?
Ver resposta
✓ Resposta: As funções do pacote viper, como viper.BindPFlag e viper.GetInt, operam numa instância global, criada pelo próprio pacote. O viper.New() cria outra, independente, sem nada do que foi feito na global. A flag foi vinculada à global, e a configuração foi lida da nova. Num teste, depois de --port 7777, a global respondia 7777 e a instância do Carregar respondia 8080, o padrão. A correção do artigo vincula a flag dentro do Carregar, na mesma instância v, recebendo o FlagSet do Cobra; com ela, a mesma CLI respondeu 8080 sem nada, 9000 com APP_SERVIDOR_PORTA=9000 e 7777 com a flag passada, mesmo com a variável definida. Uma flag vinculada e não passada não atrapalha: o padrão 0 dela não substituiu o 8080. O ideal é escolher um estilo só: ou tudo na global, ou instâncias passadas explicitamente, que são mais fáceis de testar.
Exercício 3
A primeira versão de mascararSenha procurava o primeiro @ e o : anterior a ele. Uma equipe usa a senha p@ss, e outra conecta sem senha, autenticando por certificado. O que cada uma via no log de inicialização?
Ver resposta
✓ Resposta: Com a senha p@ss, o primeiro @ está dentro da senha, e o corte deixa o resto dela à vista: num teste, postgres://usuario:p@ss@localhost/app virou postgres://usuario:***@ss@localhost/app. Um pedaço da senha no log é um vazamento, e logs costumam ser copiados para sistemas com muito mais acesso que o banco. Sem senha, não há : entre o usuário e o @, e a função encontra o : do esquema: postgres://usuario@localhost/app virou postgres:***@localhost/app, sem o usuário, o que atrapalha o diagnóstico. O url.Parse conhece a gramática das URLs, inclusive a regra de que o separador é o último @ antes do host, e o método Redacted, da biblioteca padrão desde o Go 1.15, troca só a senha por xxxxx. Na mesma medição, ele produziu postgres://usuario:xxxxx@localhost/app e deixou a URL sem senha intacta.
Exercício 4
Na versão antiga de construirConfig, quatro durações eram lidas com maxVida, _ := time.ParseDuration(...). Um operador escreve max_vida: "5 minutos" no YAML. A aplicação sobe sem reclamar. O que acontece com as conexões do banco?
Ver resposta
✓ Resposta: O ParseDuration só entende unidades como s, m e h, e devolve erro para "5 minutos", junto com o valor zero. Com o erro descartado, MaxVida fica em 0, e o GetDuration do próprio Viper faz o mesmo: num teste, ele devolveu 0s para esse valor, sem erro. Passado para SetConnMaxLifetime, o zero não significa "fechar logo", e sim "sem limite": as conexões nunca são recicladas pela idade, que é justamente o que a configuração queria evitar, por exemplo para acompanhar o failover de um banco gerenciado. O lerDuracao do artigo devolve o erro, e a mesma configuração fez a aplicação parar na subida com banco.max_vida inválido: time: unknown unit " minutos" in duration "5 minutos". O cache.ttl ganhou um padrão pelo mesmo motivo: sem ele, uma chave ausente viraria um erro de duração vazia.
Exercício 5
A ConfigValidada usa validate:"required,url" em DatabaseURL. Alguém configura DATABASE_URL=localhost:5432, esquecendo o esquema, e a validação passa. Por quê, e como apertar a regra?
Ver resposta
✓ Resposta: A regra url do validator verifica se o texto é uma URL sintaticamente válida com algum esquema, e localhost:5432 é: o que vem antes do primeiro : é lido como esquema, localhost, e o resto como conteúdo. Num teste com o validator 10.30, localhost:5432 passou, assim como a URL completa do PostgreSQL, e só um texto como não é url foi recusado. O erro só aparece na hora de conectar, com uma mensagem que fala de esquema desconhecido, longe da configuração. Para apertar a regra, o validator oferece startswith=postgres://, que pode ser combinado com url; outra saída é uma validação própria que chama url.Parse e confere u.Scheme e u.Host. Uma validação genérica responde se o formato é aceitável, e não se o valor serve para aquele uso.