Конфигурация через переменные окружения выглядит простой, пока сервис маленький. Но довольно быстро появляются обязательные параметры, дефолты, разные среды запуска и вопрос: где заканчивается удобство и начинается хаос.
Плохой вариант обычно выглядит так: os.Getenv размазан по коду, часть значений молча берётся по умолчанию, часть валидируется где-то в середине старта, а реальный набор переменных известен только тому, кто последний раз запускал сервис локально.
Рабочий baseline для Go-сервиса другой: одна структура конфигурации, явная загрузка на старте, понятная валидация, .env.example как документация и единый подход для локального запуска, Docker Compose и production.
Почему конфиг “просто через env” быстро расползается
Почти в каждом сервисе быстро появляются:
- адрес и порт HTTP-сервера;
- строка подключения к БД;
- логирование и уровень логов;
- таймауты;
- внешние endpoints и токены;
- режим окружения:
dev,stage,prod.
Если читать это по месту, получается несколько проблем:
- Непонятно, какие переменные обязательны.
- Ошибки всплывают слишком поздно, иногда уже после старта сервиса.
- Значения по умолчанию начинают жить в разных местах.
.envпревращается в мешанину из актуального, устаревшего и случайного.
Поэтому конфигурацию лучше рассматривать как отдельную часть приложения, а не как набор случайных Getenv.
Базовая структура конфигурации
Для небольшого сервиса часто достаточно одной структуры:
type Config struct {
AppEnv string // Среда запуска: dev, stage, prod
HTTPPort int // Порт HTTP-сервера
DatabaseURL string // Строка подключения к PostgreSQL
LogLevel string // Уровень логирования
ReadTimeout time.Duration // Таймаут чтения запроса
WriteTimeout time.Duration // Таймаут записи ответа
}Важный принцип: конфиг должен описывать именно runtime-параметры приложения, а не всё подряд.
То, что относится к сборке образа, version metadata и CI, не нужно тащить в runtime-конфиг сервиса.
Если сервис растёт, поля лучше группировать по подсистемам, а не держать бесконечный плоский список:
type Config struct {
AppEnv string
LogLevel string
HTTP HTTPConfig // Группа параметров HTTP-сервера
DB DBConfig // Группа параметров базы данных
}
type HTTPConfig struct {
Port int
ReadTimeout time.Duration
WriteTimeout time.Duration
}
type DBConfig struct {
URL string // DSN подключения
MaxOpenConns int // Максимум открытых соединений в пуле
MaxIdleConns int // Максимум простаивающих соединений
ConnMaxLifetime time.Duration // Время жизни соединения до пересоздания
}Это не обязательный шаг для маленького сервиса, но он хорошо помогает, когда конфиг начинает расти вместе с количеством зависимостей.
Полноценный пример, который можно запустить
Go сам по себе не читает файл .env. Если вы хотите локально поднимать приложение через .env, нужен дополнительный пакет. Чаще всего для этого берут github.com/joho/godotenv.
Установка:
go get github.com/joho/godotenvНиже пример, который можно скопировать целиком: main.go локально загружает .env, а сам config.Load() остаётся чистым loader’ом уже существующего окружения. Такой разделённый подход лучше и для тестов, и для production.
package config
import (
"fmt"
"os"
"strconv"
"strings"
"time"
)
type Config struct {
AppEnv string
HTTPPort int
DatabaseURL string
LogLevel string
ReadTimeout time.Duration
WriteTimeout time.Duration
AllowedHosts []string // Список разрешённых хостов через запятую
AdminEmails []string // Список email администраторов через запятую
}
func Load() (Config, error) {
// Необязательные параметры — с дефолтами
cfg := Config{
AppEnv: getEnv("APP_ENV", "dev"),
LogLevel: getEnv("LOG_LEVEL", "info"),
}
// Duration парсится отдельно: невалидное значение — ошибка, а не молчаливый fallback
readTimeout, err := getDuration("READ_TIMEOUT", 5*time.Second)
if err != nil {
return Config{}, fmt.Errorf("READ_TIMEOUT: %w", err)
}
cfg.ReadTimeout = readTimeout
writeTimeout, err := getDuration("WRITE_TIMEOUT", 10*time.Second)
if err != nil {
return Config{}, fmt.Errorf("WRITE_TIMEOUT: %w", err)
}
cfg.WriteTimeout = writeTimeout
// Обязательные параметры — без дефолтов, отсутствие = ошибка старта
port, err := getRequiredInt("HTTP_PORT")
if err != nil {
return Config{}, fmt.Errorf("HTTP_PORT: %w", err)
}
cfg.HTTPPort = port
dbURL, err := getRequired("DATABASE_URL")
if err != nil {
return Config{}, fmt.Errorf("DATABASE_URL: %w", err)
}
cfg.DatabaseURL = dbURL
// Списки — необязательные, пустое значение = nil
cfg.AllowedHosts = getList("ALLOWED_HOSTS")
cfg.AdminEmails = getList("ADMIN_EMAILS")
// Бизнес-валидация после загрузки всех значений
if err := cfg.Validate(); err != nil {
return Config{}, err
}
return cfg, nil
}
func (c Config) Validate() error {
// Проверка через switch: пустой case = допустимое значение
switch c.AppEnv {
case "dev", "stage", "prod":
default:
return fmt.Errorf("APP_ENV must be one of: dev, stage, prod")
}
if c.HTTPPort < 1 || c.HTTPPort > 65535 {
return fmt.Errorf("HTTP_PORT must be in range 1..65535")
}
switch c.LogLevel {
case "debug", "info", "warn", "error":
default:
return fmt.Errorf("LOG_LEVEL must be one of: debug, info, warn, error")
}
if c.ReadTimeout <= 0 {
return fmt.Errorf("READ_TIMEOUT must be positive")
}
if c.WriteTimeout <= 0 {
return fmt.Errorf("WRITE_TIMEOUT must be positive")
}
return nil
}
// getEnv возвращает значение переменной или fallback, если она не задана
func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
// getRequired возвращает ошибку, если переменная не задана
func getRequired(key string) (string, error) {
v := os.Getenv(key)
if v == "" {
return "", fmt.Errorf("is required")
}
return v, nil
}
// getRequiredInt — обязательный параметр + парсинг в число
func getRequiredInt(key string) (int, error) {
v, err := getRequired(key)
if err != nil {
return 0, err
}
n, err := strconv.Atoi(v)
if err != nil {
return 0, fmt.Errorf("must be an integer")
}
return n, nil
}
// getDuration — парсит duration (например "5s", "1m"), при ошибке не подставляет fallback
func getDuration(key string, fallback time.Duration) (time.Duration, error) {
v := os.Getenv(key)
if v == "" {
return fallback, nil
}
d, err := time.ParseDuration(v)
if err != nil {
return 0, fmt.Errorf("must be a valid duration like 5s or 1m")
}
return d, nil
}
// getList — разбивает значение по запятой, убирая пробелы и пустые элементы
func getList(key string) []string {
v := os.Getenv(key)
if v == "" {
return nil
}
parts := strings.Split(v, ",")
result := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part != "" {
result = append(result, part)
}
}
return result
}Минимальный main.go:
package main
import (
"log/slog"
"os"
"github.com/joho/godotenv"
"example/internal/config"
)
func main() {
// Загружаем .env для локальной разработки; в production файла нет — ошибка игнорируется
_ = godotenv.Load()
// Загрузка и валидация конфига — при ошибке сервис не запускается
cfg, err := config.Load()
if err != nil {
slog.Error("config error", "err", err)
os.Exit(1)
}
slog.Info(
"config loaded",
"env", cfg.AppEnv,
"port", cfg.HTTPPort,
"allowed_hosts", cfg.AllowedHosts,
"admin_emails", cfg.AdminEmails,
)
}.env.example для такого примера:
# Среда запуска: dev | stage | prod
APP_ENV=dev
HTTP_PORT=8081
DATABASE_URL=postgres://user:password@localhost:5432/app?sslmode=disable
LOG_LEVEL=info
# Формат duration: 5s, 1m, 500ms
READ_TIMEOUT=5s
WRITE_TIMEOUT=10s
# Списки через запятую, пробелы обрезаются
ALLOWED_HOSTS=localhost,127.0.0.1,api.local
ADMIN_EMAILS=admin@example.com,ops@example.comСписки здесь читаются через запятую:
ALLOWED_HOSTS=localhost,127.0.0.1,api.localпревратится в[]string{"localhost", "127.0.0.1", "api.local"};ADMIN_EMAILS=admin@example.com,ops@example.comпревратится в[]string{"admin@example.com", "ops@example.com"}.
Этого уже достаточно для allowlists, простых списков адресов, feature flags и получателей уведомлений.
Минимальный loader без магии
На старте вполне можно обойтись стандартной библиотекой:
package config
import (
"fmt"
"os"
"strconv"
"time"
)
type Config struct {
AppEnv string
HTTPPort int
DatabaseURL string
LogLevel string
ReadTimeout time.Duration
WriteTimeout time.Duration
}
func Load() (Config, error) {
// Параметры с дефолтами — безопасны для локального запуска
cfg := Config{
AppEnv: getEnv("APP_ENV", "dev"),
LogLevel: getEnv("LOG_LEVEL", "info"),
}
// Таймауты: невалидный формат (не "5s", "1m" и т.д.) — ошибка, не fallback
readTimeout, err := getDuration("READ_TIMEOUT", 5*time.Second)
if err != nil {
return Config{}, fmt.Errorf("READ_TIMEOUT: %w", err)
}
cfg.ReadTimeout = readTimeout
writeTimeout, err := getDuration("WRITE_TIMEOUT", 10*time.Second)
if err != nil {
return Config{}, fmt.Errorf("WRITE_TIMEOUT: %w", err)
}
cfg.WriteTimeout = writeTimeout
// Обязательные параметры: отсутствие = ошибка на старте
port, err := getRequiredInt("HTTP_PORT")
if err != nil {
return Config{}, fmt.Errorf("HTTP_PORT: %w", err)
}
cfg.HTTPPort = port
dbURL, err := getRequired("DATABASE_URL")
if err != nil {
return Config{}, fmt.Errorf("DATABASE_URL: %w", err)
}
cfg.DatabaseURL = dbURL
if err := cfg.Validate(); err != nil {
return Config{}, err
}
return cfg, nil
}
func (c Config) Validate() error {
switch c.AppEnv {
case "dev", "stage", "prod":
default:
return fmt.Errorf("APP_ENV must be one of: dev, stage, prod")
}
if c.HTTPPort < 1 || c.HTTPPort > 65535 {
return fmt.Errorf("HTTP_PORT must be in range 1..65535")
}
switch c.LogLevel {
case "debug", "info", "warn", "error":
default:
return fmt.Errorf("LOG_LEVEL must be one of: debug, info, warn, error")
}
if c.ReadTimeout <= 0 {
return fmt.Errorf("READ_TIMEOUT must be positive")
}
if c.WriteTimeout <= 0 {
return fmt.Errorf("WRITE_TIMEOUT must be positive")
}
return nil
}
func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
func getRequired(key string) (string, error) {
v := os.Getenv(key)
if v == "" {
return "", fmt.Errorf("is required")
}
return v, nil
}
func getRequiredInt(key string) (int, error) {
v, err := getRequired(key)
if err != nil {
return 0, err
}
n, err := strconv.Atoi(v)
if err != nil {
return 0, fmt.Errorf("must be an integer")
}
return n, nil
}
func getDuration(key string, fallback time.Duration) (time.Duration, error) {
v := os.Getenv(key)
if v == "" {
return fallback, nil
}
d, err := time.ParseDuration(v)
if err != nil {
return 0, fmt.Errorf("must be a valid duration like 5s or 1m")
}
return d, nil
}Это не единственный вариант, но для небольшого и среднего сервиса он уже даёт главное:
- все параметры собраны в одном месте;
- обязательные значения читаются явно;
- дефолты не размазаны по проекту;
- невалидные duration и числа не маскируются молчаливым fallback;
- приложение падает рано и предсказуемо.
Как падать быстро и понятно
Если конфигурация сломана, сервис лучше не запускать вообще.
Хороший старт приложения выглядит примерно так:
func main() {
cfg, err := config.Load()
if err != nil {
slog.Error("config error", "err", err)
os.Exit(1)
}
slog.Info("config loaded", "env", cfg.AppEnv, "port", cfg.HTTPPort)
// дальше инициализация БД, HTTP-сервера и всего остального
}Идея простая: конфигурация валидируется один раз, до инициализации зависимостей.
Не нужно тянуть невалидные значения дальше в код и надеяться, что ошибка “сама всплывёт”.
Что делать с дефолтами
У дефолтов есть одно полезное правило:
- для локального удобства они допустимы;
- для критичных параметров лучше требовать явное значение.
Хорошие кандидаты на дефолт:
APP_ENV=dev;LOG_LEVEL=info;- таймауты;
- локальный HTTP-порт.
Плохие кандидаты на дефолт:
DATABASE_URL;- токены;
- внешние credentials;
- адреса критичных внешних систем.
Если у секрета или инфраструктурного адреса “случайно” появляется дефолт, это почти всегда источник трудноуловимых проблем.
.env, .env.example и документация к запуску
Для локальной разработки .env — нормальный инструмент. Но реальные значения не должны жить в репозитории.
Важно не путать две вещи:
.envкак удобный локальный файл со значениями;- отдельный пакет вроде
godotenv, который эти значения реально загружает в процесс приложения.
Хорошая практика: godotenv использовать в main.go или в локальном bootstrap-слое, а config.Load() держать независимым от файловой системы. Тогда loader просто читает окружение, а источник этого окружения остаётся внешним и явным.
Обычно достаточно такой схемы:
.env— локальный файл разработчика, в.gitignore;.env.example— шаблон без секретов, но со всеми ключами;- production-среда — реальные env vars из внешнего окружения.
Пример .env.example:
# Runtime
APP_ENV=dev
HTTP_PORT=8081
LOG_LEVEL=info
READ_TIMEOUT=5s
WRITE_TIMEOUT=10s
ALLOWED_HOSTS=localhost,127.0.0.1
# Infrastructure
DATABASE_URL=postgres://user:password@localhost:5432/app?sslmode=disable.env.example полезен не только для запуска, но и как живая документация конфигурации сервиса.
Если переменных становится больше, лучше группировать их по смыслу и держать одинаковый порядок:
- сначала общие runtime-параметры;
- потом сеть и HTTP;
- потом БД;
- потом внешние интеграции;
- потом observability и вспомогательные настройки.
Это мелочь, но такой порядок сильно упрощает сопровождение и снижает шанс, что .env.example превратится в случайный набор строк.
Когда стоит взять библиотеку
Если проект становится больше, можно использовать библиотеку вроде caarlos0/env, чтобы убрать часть шаблонного кода.
Например:
type Config struct {
AppEnv string `env:"APP_ENV" envDefault:"dev"` // Дефолт через тег
HTTPPort int `env:"HTTP_PORT,required"` // required — ошибка, если нет
DatabaseURL string `env:"DATABASE_URL,required"` // Секреты — всегда required
LogLevel string `env:"LOG_LEVEL" envDefault:"info"`
ReadTimeout time.Duration `env:"READ_TIMEOUT" envDefault:"5s"` // Парсинг duration автоматический
WriteTimeout time.Duration `env:"WRITE_TIMEOUT" envDefault:"10s"`
AllowedHosts []string `env:"ALLOWED_HOSTS" envSeparator:","` // Разделитель для списков
}Это удобно, но не отменяет главного:
- структура конфигурации должна быть одной;
- обязательные и необязательные параметры должны быть видны сразу;
- бизнес-валидация всё равно должна жить отдельно.
То есть библиотека убирает boilerplate, но не заменяет архитектуру конфигурации.
Ещё один практичный критерий: если у вас 5-10 переменных, стандартной библиотеки обычно достаточно.
Если параметров уже десятки, а типов и обязательных полей становится всё больше, библиотека начинает окупаться довольно быстро.
Что ещё умеет caarlos0/env
В статье выше показан только базовый сценарий, но у библиотеки есть ещё несколько полезных возможностей.
envPrefix для группировки параметров
Удобно, если конфиг разбит на вложенные структуры:
type Config struct {
HTTP HTTPConfig `envPrefix:"HTTP_"` // Поля читают HTTP_PORT, HTTP_TIMEOUT и т.д.
DB DBConfig `envPrefix:"DB_"` // Поля читают DB_URL, DB_MAX_CONNS и т.д.
}
type HTTPConfig struct {
Port int `env:"PORT" envDefault:"8081"` // Итоговая переменная: HTTP_PORT
}
type DBConfig struct {
URL string `env:"URL,required"` // Итоговая переменная: DB_URL
}Тогда переменные будут читаться как HTTP_PORT и DB_URL.
notEmpty для обязательных непустых строк
Если мало проверить, что переменная существует, и нужно запретить пустую строку:
type Config struct {
APIKey string `env:"API_KEY,required,notEmpty"`
}Это полезно для токенов, DSN и других строковых параметров, где пустое значение почти всегда ошибка.
file для секретов из файлов
Это особенно удобно в Docker и Kubernetes, когда секрет передаётся не напрямую через env, а через файл:
type Config struct {
// Библиотека прочитает содержимое файла по пути из переменной
DBPassword string `env:"DB_PASSWORD_FILE,file"`
}Если DB_PASSWORD_FILE=/run/secrets/db_password, библиотека прочитает содержимое файла и положит его в поле.
Кастомный парсинг для своих типов
Если стандартных string, int, bool, time.Duration и []string уже не хватает, можно добавить свой парсер.
Например, для enum-значения:
type AppMode string
const (
ModeReadOnly AppMode = "readonly"
ModeRW AppMode = "readwrite"
)
type Config struct {
Mode AppMode `env:"APP_MODE" envDefault:"readonly"`
}Для production-сценариев чаще всего хватает четырёх вещей:
envPrefixдля вложенных структур;requiredиnotEmptyдля обязательных строк;fileдля секретов;envSeparatorили кастомного парсинга для списков и своих типов.
Конфиг тоже стоит тестировать
Поскольку загрузка конфигурации собрана в одном месте, её довольно легко проверить обычными unit-тестами.
Например:
func TestLoad(t *testing.T) {
// t.Setenv — безопасно устанавливает env на время теста, восстанавливает после
t.Setenv("APP_ENV", "dev")
t.Setenv("HTTP_PORT", "8081")
t.Setenv("DATABASE_URL", "postgres://user:pass@localhost:5432/app?sslmode=disable")
t.Setenv("LOG_LEVEL", "info")
t.Setenv("READ_TIMEOUT", "5s")
t.Setenv("WRITE_TIMEOUT", "10s")
cfg, err := Load()
if err != nil {
t.Fatalf("Load() returned error: %v", err)
}
if cfg.HTTPPort != 8081 {
t.Fatalf("expected port 8081, got %d", cfg.HTTPPort)
}
if cfg.DatabaseURL == "" {
t.Fatal("expected DATABASE_URL to be set")
}
}Отдельно полезно проверять негативные сценарии: отсутствующий DATABASE_URL, невалидный HTTP_PORT или сломанный READ_TIMEOUT.
Такой тест быстро ловит регрессии, когда кто-то меняет loader, дефолты или правила валидации.
Связка с Docker Compose
В контейнерной среде конфигурация остаётся runtime-конфигом, а не частью образа.
Пример:
services:
api:
image: example/api:latest
env_file:
- .env # Загрузка переменных из файла
environment:
APP_ENV: dev # Явные значения переопределяют .env
HTTP_PORT: 8081
ports:
- "8081:8081"Здесь важно не путать две вещи:
- build-time параметры нужны для сборки образа;
- runtime env vars нужны самому приложению во время запуска.
DATABASE_URL, LOG_LEVEL и таймауты — это runtime.
Версия бинарника, commit sha или build tags — это уже не конфиг приложения, а метаданные сборки.
Типичные ошибки
- Читать
os.Getenvпрямо в разных пакетах вместо одного loader. - Хранить реальные секреты в
.envи коммитить их в репозиторий. - Давать дефолты там, где параметр должен быть обязательным.
- Валидировать конфиг слишком поздно, после старта зависимостей.
- Смешивать build-time и runtime параметры.
- Не держать
.env.exampleв актуальном состоянии. - Молча подставлять fallback для невалидного значения вместо явной ошибки старта.
Небольшой checklist
- есть одна структура
Config; - загрузка происходит в одном месте;
- обязательные параметры валидируются на старте;
- у дефолтов есть осознанная причина;
.envне хранится в Git;.env.exampleотражает реальный набор параметров;- Docker и Compose передают runtime-конфиг, а не подменяют архитектуру приложения.
Конфигурация через env хороша не тем, что она “простая сама по себе”, а тем, что её легко сделать предсказуемой. Когда конфиг собран в одном месте, валидируется на старте и одинаково работает локально и в контейнере, сервис ведёт себя намного спокойнее.
Документация и первоисточники
Если захочется сверить детали по библиотекам и стандартному поведению, полезно держать под рукой:
- os package — стандартная библиотека Go для работы с окружением;
- time.ParseDuration — официальный формат duration-строк;
- joho/godotenv — загрузка
.envв локальной разработке; - caarlos0/env — env-парсер для структур и типизированного конфига.
Статья при этом остаётся не справкой по библиотекам, а практическим baseline: как выстроить предсказуемую конфигурацию сервиса, а не как перечислить все опции пакетов.
Комментарии