---
title: "Профили dev, staging и prod"
description: "Профили окружений Go: config.dev.yaml и config.prod.yaml, выбор через флаг --env и APP_ENV, MergeInConfig для переопределений. Секреты — только в env деплоя."
course: "Viper: конфигурация приложений на Go"
url: "https://golangmaster.ru/courses/viper/lessons/16-profili-okruzhenij/"
published: "2026-09-19"
updated: "2026-09-27"
minutes: 18
---

# Профили dev, staging и prod

*Курс «Viper: конфигурация приложений на Go», урок 16 из 25. ~18 минут. Обновлено 27.09.2026. Источник: https://golangmaster.ru/courses/viper/lessons/16-profili-okruzhenij/*

Профили окружений Go собирают dev, staging и prod в один бинарник: viper читает config.dev.yaml или config.prod.yaml по флагу --env и переменной APP_ENV, файлы задают базу, окружение деплоя перекрывает её поверх. Разберём суффиксы и папки, MergeInConfig для переопределений, префиксы env и место секретов.

Сервис собирается один раз, а живёт в трёх местах: ноутбук разработчика, staging с тестовыми интеграциями и prod с боевыми базами. Настройки при этом разные — порт, уровень логов, адреса соседей, — и самый дешёвый способ развести их — файлы по суффиксу окружения: config.dev.yaml и config.prod.yaml в репозитории. Бинарник остаётся один: на старте он узнаёт контур из флага или переменной APP_ENV и читает свой профиль через SetConfigName("config." + env). В этом уроке собираем каркас профилей окружений Go: выбор контура с белым списком, общая база с переопределениями через MergeInConfig, третий слой из переменных окружения и правило, по которому секреты в файлах не живут вовсе. Заодно — история о том, как staging отправил письма клиентам, и спор о том, где вообще должен лежать конфиг.

### Профили окружений Go: выбор через флаг --env или APP_ENV

Выбор окружения делается до первого обращения к viper: контур — это аргумент того, какой файл читать, а не настройка внутри конфига. Классическая цепочка: флаг --env, затем переменная APP_ENV, затем дефолт. Флаг удобен локально и в скриптах; в контейнерах флаг прокинуть не всегда возможно, там выручает APP_ENV из манифеста деплоя. [BindPFlag](/courses/viper/lessons/08-flagi-cobra/) связывает флаг с реестром для обычных настроек, но сам выбор файла проще сделать до viper: имя контура нужно как часть имени файла, а не как значение настройки. Дальше — белый список: switch с тремя допустимыми значениями или карта валидных контуров. Без него опечатка APP_ENV=produciton превращается в SetConfigName("config.produciton"), ReadInConfig не находит файл, и сервис едет на дефолтах — тихо и по всем правилам библиотеки. Валидация контура — это валидация имени файла, и стоит она четыре строки.

### Выбор окружения и чтение профиля

```go
env := flag.String("env", "", "dev, staging или prod")
flag.Parse()

if *env == "" {
    *env = os.Getenv("APP_ENV")
}
if *env == "" {
    *env = "dev" // провал по умолчанию — в самое безопасное место
}

switch *env {
case "dev", "staging", "prod":
default:
    log.Fatalf("неизвестное окружение: %q", *env)
}

v := viper.New()
v.SetConfigName("config." + *env)
v.AddConfigPath("./configs")
if err := v.ReadInConfig(); err != nil {
    log.Fatalf("профиль %s: %v", *env, err)
}
fmt.Println("окружение:", *env, "порт:", v.GetInt("server.port"))
```

Цепочка выбора: флаг, затем APP_ENV, затем dev. Switch отбрасывает опечатки до того, как они превратятся в имя файла. SetConfigName с суффиксом ищет config.<env>.yaml по всем добавленным путям; ReadInConfig вернёт ошибку, если файла нет, — для обязательного профиля это Fatal, а не тихий проход дальше. Запустите локальным Go: go build, затем ./app --env staging и ./app без аргументов — в логе два разных файла.

### База и переопределения: config.yaml плюс config.prod.yaml

Профили по суффиксу удобны, когда контуры отличаются сильно. Если же девяносто процентов настроек общие, плодить пять почти одинаковых файлов — путь к рассинхрону: правку таймаута придётся повторять в каждом. Выход — два слоя файлов: общий config.yaml хранит базу, а config.<env>.yaml — только отличия контура. Читаются они в один реестр последовательно: сначала ReadInConfig базы, затем SetConfigName("config." + env) и MergeInConfig — она вливает карту профиля поверх уже прочитанной: скаляры заменяются, вложенные карты сливаются по ключам. Второй подряд ReadInConfig не годится: он заменяет содержимое целиком, и общие ключи базы пропадут. Отдельные реестры через viper.New() пригодятся, когда базу и оверрайд нужно видеть по отдельности — в тестах конфигурации или при сравнении контуров; там каждый файл читается в свой реестр, а сравнение значений делается обычным кодом.

### Общая база и MergeInConfig

```go
v := viper.New()

// база: всё общее между контурами
v.SetConfigName("config")
v.AddConfigPath("./configs")
if err := v.ReadInConfig(); err != nil {
    log.Fatal("нет базового конфига: ", err)
}

// переопределения: только отличия контура
v.SetConfigName("config." + env)
if err := v.MergeInConfig(); err != nil {
    log.Fatalf("нет профиля %s: %v", env, err)
}
```

База читается первым вызовом, профиль вливается MergeInConfig поверх: retries из config.dev.yaml заменил базовое значение, а ключи, которых в профиле нет, остались из базы. Вложенные секции сливаются по ключам, а не заменяются целиком — именно поэтому общая база выживает.

> **Профиль не найден: ошибка или молчание.** MergeInConfig возвращает ConfigFileNotFoundError, если файла профиля нет. Для обязательного профиля это Fatal на старте; глотать ошибку нельзя — сервис поедет на одной базе, как будто контура не существует. Если же переопределения по задумке опциональны, проверяйте именно тип ошибки через errors.As и пропускайте только «файл не найден», а всё остальное — роняйте.

### Третий слой: переменные окружения поверх файлов

Файлы профиля задают базу, но деплой всегда знает лучше: порт из манифеста, адрес базы из секрета, уровень логов из переменной контейнера. Третий слой включается из урока про [AutomaticEnv](/courses/viper/lessons/06-okruzhenie/): SetEnvKeyReplacer, SetEnvPrefix и AutomaticEnv — и каждое чтение проверяет окружение. Лестница остаётся прежней: [флаг выше env, env выше файла, файл выше дефолта](/courses/viper/lessons/09-prioritety-istochnikov/) — профиль перекрывается переменными так же, как и одиночный config.yaml. Практика деплоя, которую стоит скопировать: префикс по контуру. На staging SetEnvPrefix("STG") — процесс ищет только STG_SERVER_PORT и физически не прочитает APP_SECRET, даже если манифест собран криво и чужая переменная влетела в контейнер. Суффикс имени файла защищает от чтения чужого YAML, префикс переменных — от чтения чужих секретов; вместе они закрывают оба пути, которыми профили путаются местами.

### Префикс по контуру и AutomaticEnv

```go
var prefixes = map[string]string{
    "dev": "APP", "staging": "STG", "prod": "PRD",
}

v.SetEnvPrefix(prefixes[env])
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
v.AutomaticEnv()

// запуск staging: STG_SERVER_PORT=8081 ./app --env staging
fmt.Println(v.GetInt("server.port")) // 8081 — env перекрыл файл
```

Префикс выбирается из карты по контуру и действует на каждое чтение: на staging viper проверит STG_SERVER_PORT и не увидит APP_SERVER_PORT, даже если та задана. Replacer превращает server.port в SERVER_PORT — механика из урока про окружение. Строка запуска показывает третий слой в деле: переменная деплоя перекрыла файл профиля.

### Окружение → файл → env-префикс

| Окружение | Файл профиля | Префикс env |
| --- | --- | --- |
| dev | config.dev.yaml | APP_ — локально можно и без префикса |
| staging | config.staging.yaml | STG_ — свой секрет-неймспейс контура |
| prod | config.prod.yaml | PRD_ — только боевые переменные |
| тесты CI | config.test.yaml | TEST_ — чтобы тесты не увидели деплой |

### Что лежит в файлах, что в переменных

- В файлах репозитория: порт, уровень логов, таймауты, адреса соседних сервисов — нечувствительные настройки с историей в git.
- В переменных окружения деплоя: пароли баз, ключи API, токены — секреты не попадают в коммиты, подробности в следующем уроке.
- В config.yaml — общая база; в config.<env>.yaml — только отличия: дифф между контурами виден с одного взгляда.
- Каждый контур получает свой префикс: процесс staging не прочитает переменную APP_*, даже если она влетела в контейнер.
- Флаг --env выбирает профиль локально, APP_ENV — запасной путь для контейнеров, куда флаг не прокинут.
- Строка env=staging file=config.staging.yaml в логе на старте — дешёвая страховка от перепутанных профилей.

> **Суффиксы или папки configs/dev.** Два способа разложить профили, и viper умеет оба. Суффиксы — config.dev.yaml в одной папке: весь конфиг на одном экране, diff между контурами и grep по ключам без прыжков по дереву. Папки — configs/dev/config.yaml с одинаковыми именами файлов: манифест или шаблон CI меняет только AddConfigPath("configs/" + env), а имя файла остаётся неизменным. Практическое правило: если конфиг чаще правят руками — суффиксы, если генерирует CI — папки.

### Живое место: staging отправил письма клиентам

История, ради которой стоит печатать окружение на старте. Команда завела переменную APP_ENV и выставила её в манифестах — но в манифесте staging строку забыли. Дефолт в коде был prod: рассуждали, что на локалке контур всё равно передают явно, а в деплое опаснее случайно упасть в dev с его заглушками. Бинарник на staging прочитал config.prod.yaml: боевой SMTP-хост, боевой адрес базы рассылок. Тестовый прогон уведомления ушёл по боевым спискам — клиентам. Разбор дал три правила, которые я теперь ставлю в каждый каркас. Первое: дефолт-окружение — dev, провал по умолчанию должен быть в самое безопасное место. Второе: в деплое выбор обязателен — пустая APP_ENV в непомеченном как локальный запуске должна ронять старт с понятной ошибкой, а не подставлять файл. Третье: печать env и имени файла на старте превращает расследование из вечера в тридцать секунд.

### Проверка: пропавший манифест

**Вопрос:** В манифесте staging забыли задать APP_ENV. Код делает env := os.Getenv("APP_ENV"); if env == "" { env = "dev" }. Какой файл прочитает бинарник на staging?

- config.staging.yaml — viper угадывает окружение по имени хоста
- ✅ config.dev.yaml — сработает дефолт: пустая переменная заменится на dev
- config.prod.yaml — пустая APP_ENV означает prod по умолчанию
- никакой — без APP_ENV viper откажется читать конфиг

viper ничего не знает о APP_ENV — это ваша строка кода. Пустая переменная заменяется дефолтом, и staging едет с чужими настройками. Белый список, различение локального запуска и печать выбора на старте существуют именно для такого случая.

### Проверка: второй ReadInConfig

**Вопрос:** База прочитана через ReadInConfig. На том же реестре после SetConfigName("config.prod") вызван второй ReadInConfig. Что станет с общими настройками базы?

- ничего — второй вызов только добавит отличия профиля
- ✅ база заменится целиком: ReadInConfig перечитывает карту с нуля, общие ключи пропадут
- ошибка — на одном реестре можно читать только один файл
- значения сложатся — числовые настройки просуммируются

ReadInConfig заменяет карту конфига целиком: ключи базы, которых нет в профиле, исчезнут. Наложить профиль поверх базы позволяет MergeInConfig — она сливает значения, сохраняя общие ключи.

### Что выведет программа?

```go
// configs/config.yaml:     retries: 3
// configs/config.dev.yaml: retries: 5

v := viper.New()
v.SetConfigName("config")
v.AddConfigPath("./configs")
v.ReadInConfig()

v.SetConfigName("config.dev")
v.MergeInConfig()

fmt.Println(v.GetInt("retries"))
```

**Что выведет программа?**

- 3: базовый файл задал значение первым
- ✅ 5: переопределение из config.dev.yaml затёрло базу
- 8: MergeInConfig суммирует числа
- 0: второй файл затёр всю карту базы

MergeInConfig вливает карту профиля поверх базы: скаляр retries заменяется пятёркой, а ключи, которых в профиле нет, остаются из config.yaml. Списки и скаляры заменяются целиком, вложенные карты сливаются по ключам — поэтому общая база не теряется.

### Соберите выбор профиля

Допишите чтение переменной, создание отдельного реестра и имя файла с суффиксом окружения.

```go
env := os.___("APP_ENV")
if env == "" {
    env = "dev"
}

v := viper.___()
v.___("config." + env)
v.AddConfigPath("./configs")
if err := v.ReadInConfig(); err != nil {
    log.Fatal(err)
}
```

Подсказка: Переменную читает os.Getenv, отдельный реестр создаёт viper.New(), имя файла задаёт SetConfigName.

Правильные ответы в пропусках: `Getenv`, `New`, `SetConfigName`

### Соберите каркас профиля

Расставьте строки: выбор окружения, отдельный реестр, имя файла, путь поиска, чтение конфига и печать порта.

Правильный порядок строк:

1. `env := os.Getenv("APP_ENV")`
2. `v := viper.New()`
3. `v.SetConfigName("config." + env)`
4. `v.AddConfigPath("./configs")`
5. `err := v.ReadInConfig()`
6. `fmt.Println(v.GetInt("server.port"))`

### Карточки: профили окружений

- **SetConfigName с суффиксом** — v.SetConfigName("config." + env) — viper ищет config.dev.yaml или config.prod.yaml по всем путям из AddConfigPath. Имя контура приходит из флага или APP_ENV.
- **APP_ENV** — Переменная контейнера с именем контура: dev, staging, prod. Пустая или опечатанная — повод для проверки и падения на старте, а не для тихого дефолта в опасное окружение.
- **MergeInConfig** — Вливает второй конфиг-файл поверх уже прочитанного: скаляры заменяются, вложенные карты сливаются по ключам. Базовый ReadInConfig + MergeInConfig профиля — стандарт для базы и переопределений.
- **Слои конфига** — Порядок приоритетов: флаг выше env, env выше файла профиля, файл выше дефолтов. Файлы описывают базу, переменные деплоя перекрывают точечно.
- **Префикс по контуру** — SetEnvPrefix("STG") на staging: процесс ищет только STG_* и не прочитает чужие APP_*-переменные, даже если они влетели в контейнер. Файл защищает суффикс, секреты — префикс.
- **Суффиксы или папки** — config.dev.yaml в одной папке — диффы и grep в одном месте; configs/dev/config.yaml — одинаковые имена для шаблонов CI: путь меняется в AddConfigPath("configs/" + env). Viper умеет оба варианта.

### Спор: конфиг на файловой системе или всё в переменных деплоя

Две школы конфигурации спорят об одном и том же слое. Файловая школа хранит настройки в YAML рядом с кодом: история в git, ревью на правки, дифф между релизами виден без инструментов. Плата — файловая система: в Kubernetes файлы монтировать неудобно, а соблазн положить в тот же файл секрет огромен. Школа переменных кладёт в env вообще всё: манифест деплоя становится единственным источником, маунтов нет, окружение неизменяемо. Плата — слепота инструментов: AllKeys не видит AutomaticEnv, история значений живёт только в истории манифестов, а вложенные структуры превращаются в простыни вроде DATABASE_HOST без иерархии. Мой критерий: если конфиг правят люди и контуров больше одного — файлы для базы плюс env для перекрытий и секретов, как в этом уроке. Всё в переменных оправдано, когда платформа деплоя сама валидирует схему и держит секреты отдельно, а сервису остаётся читать плоские значения с проверкой IsSet на старте.

### Что дальше

Каркас профилей собран: флаг и APP_ENV выбирают контур, база с переопределениями живёт в двух файлах, префикс отделяет секреты одного окружения от другого. Осталась незакрытой главная дыра — секреты: что делать с паролем базы, если в файл его класть нельзя, а в переменные он попадает через деплой. Следующий урок разбирает именно это: почему пароли не живут в YAML, что делает история git с однажды закоммиченным ключом и как устроить переменные деплоя, чтобы секрет доходил до реестра, не засвечиваясь в логах. Общий маршрут по темам — на [странице курса viper](/courses/viper/), а каркас профиля из урока стоит прогнать у себя: два контура, два запуска, два разных файла в логе.

## Коротко: что запомнить

- Один бинарник на все контуры: окружение выбирается флагом --env с запасной APP_ENV, белым списком и печатью выбора на старте.
- SetConfigName("config." + env) с AddConfigPath читает профиль по суффиксу; папки configs/dev/ — альтернатива с одинаковыми именами файлов.
- Второй ReadInConfig затирает базу целиком; чтобы наложить отличия контура поверх общей базы, нужна MergeInConfig.
- Переменные окружения — третий слой поверх файлов: флаг выше env, env выше файла, файл выше дефолтов.
- Префикс по контуру через SetEnvPrefix отделяет секреты: процесс staging не прочитает чужие APP_*-переменные.
- В файлах живут нечувствительные настройки с историей в git; секреты — только в окружении деплоя.
- Пустая APP_ENV — не «не задано», а ваш дефолт: провал должен быть в dev, а выбор окружения обязан быть явным в деплое.

## Домашнее задание

Соберите каркас из урока: configs/config.yaml с портом и уровнем логов, config.dev.yaml и config.prod.yaml с отличиями. Выбор окружения — флаг с запасным APP_ENV и белым списком. Запустите локальным Go оба контура и сравните вывод, затем уберите APP_ENV из запуска и посмотрите в логе, какой файл прочитался на самом деле.

## Вопросы и ответы по уроку

### Нужен ли отдельный бинарник или образ на каждое окружение?

Нет — в этом смысл профилей. Один бинарник читает config.dev.yaml или config.prod.yaml по флагу или APP_ENV. Собирать отдельный образ под контур — путь к рассинхрону: рано или поздно на prod уедет сборка для staging.

### Что делать с секретами в профилях?

Не класть их в файлы вовсе: в config.prod.yaml живут только нечувствительные отличия вроде порта и уровня логов. Пароли и ключи приходят переменными окружения деплоя с префиксом контура — полный разбор в следующем уроке про секреты.

### MergeInConfig заменяет значения или складывает?

Заменяет скаляры и глубоко сливает карты: ключ из профиля затирает базу, вложенные секции объединяются по ключам, а списки заменяются целиком. Проверьте на своём YAML: две минуты с базой и профилем дают точную картину слияния.
