Переменные окружения и конфигурация
Приложение должно деплоиться на разные окружения без правки кода. Для этого конфигурация выносится в переменные окружения: адреса API, флаги функций, ключи. Разберём, как устроены build-time и runtime-переменные, .env файлы, хранение секретов и валидация конфигурации.
Содержание
- 12-factor: конфигурация вне кода
- Build-time vs runtime
- .env файлы
- Порядок загрузки
- Что можно в бандл, а что нельзя
- Секреты и где их хранить
- .env.example и документация
- Валидация конфигурации через zod
- Runtime-конфигурация в SPA
- Переменные в CI/CD и платформах деплоя
- Лучшие практики
- Антипаттерны
12-factor: конфигурация вне кода
Двенадцатый фактор — конфигурация — гласит: всё, что меняется между окружениями (dev, staging, production), должно выноситься из кода в переменные окружения.
Код не содержит адресов, ключей и флагов:
// ❌ Плохо: конфигурация зашита в код
const API_URL = 'https://api.production.example.com';
// ✅ Хорошо: значение приходит из окружения
const API_URL = import.meta.env.VITE_API_URL;
Преимущества:
- Один и тот же билд деплоится везде.
- Секреты не попадают в репозиторий.
- Окружение переключается без переписывания кода.
Build-time vs runtime
Build-time переменные
Подставляются в бандл во время сборки:
VITE_API_URL=https://api.example.com npm run build
const API_URL = import.meta.env.VITE_API_URL; // уже вшито в JS
Встраиваются навсегда — изменить можно только пересборкой. Это касается VITE_* (Vite), NEXT_PUBLIC_* (Next.js), NUXT_PUBLIC_* (Nuxt), REACT_APP_* (CRA).
Runtime переменные
Задаются при запуске приложения/контейнера:
docker run -e API_URL=https://api.example.com my-app
const API_URL = process.env.API_URL; // читается в runtime
Применимы к SSR-серверу, API-роутам, бэкенду. Для чистого SPA runtime-переменные требуют подстановки на сервере (см. раздел про runtime-конфигурацию).
Правило: build-time — для несекретного и известного на сборке; runtime — для того, что меняется между окружениями без пересборки.
.env файлы
Vite, Next.js и Nuxt автоматически загружают .env файлы из корня проекта.
| Файл | Назначение |
|---|---|
.env | Общие значения для всех окружений. |
.env.local | Локальные переопределения, не коммитится. |
.env.development | Значения для dev-режима. |
.env.production | Значения для production-сборки. |
.env.[mode].local | Локальные для конкретного режима, не коммитится. |
Пример:
# .env
VITE_APP_NAME=My App
# .env.development
VITE_API_URL=http://localhost:3000
# .env.production
VITE_API_URL=https://api.example.com
mode для Vite: development / production; для Next.js: development / production / test.
Порядок загрузки
Приоритет (большее переопределяет меньшее):
.env.local
.env.[mode].local
.env.[mode]
.env
Важные нюансы:
.env.localне коммитится и должен быть в.gitignore.- Для production
.env.localигнорируется вовсе (Vite) — защита от случайных локальных значений в проде. - Конкретные значения (
.env.production) переопределяют общие (.env). - Итоговое значение зависит от
mode, в котором запущена сборка.
Порядок проверяется через dotenv-логику инструмента сборки — не полагайтесь на интуицию, тестируйте.
Что можно в бандл, а что нельзя
В клиентский бандл попадает только то, что явно помечено публичным префиксом:
import.meta.env.VITE_API_URL; // попадёт в бандл
import.meta.env.VITE_APP_NAME; // попадёт
import.meta.env.SECRET_KEY; // НЕ попадёт — нет префикса VITE_
Можно: адреса API, идентификаторы аналитики, feature-флаги, название приложения — всё несекретное.
Нельзя: ключи API, пароли, токены, секреты БД. Любой пользователь откроет DevTools и увидит их в собранном JS.
Секреты в клиентский бандл не кладут никогда. Для работы с платёжными API на клиенте используются прокси-эндпоинты и publishable-ключи, а не серверные секреты.
Секреты и где их хранить
Секреты — переменные, которые дают доступ к ресурсам: API-ключи, токены, пароли.
Хранилища:
- Переменные окружения платформы — Vercel, Netlify, Railway: зашифрованы, доступны при деплое.
- Secrets CI/CD — GitHub Actions secrets, GitLab CI/CD variables — доступны в пайплайне.
- Vault (HashiCorp) — централизованное хранилище секретов для инфраструктуры.
- Docker secrets — для контейнеров.
В репозитории секретов нет. Только .env.example с заглушками.
# .gitignore — обязательные строки
.env
.env.local
.env.*.local
.env.production
Если секрет уже закоммичен — он в истории git, удаление файла не помогает. Секрет нужно отозвать и перевыпустить.
.env.example и документация
.env.example — коммитимый шаблон, документирующий все переменные проекта:
# Адрес API. Для dev — http://localhost:3000
VITE_API_URL=
# Включить/выключить фичу поиска
VITE_FEATURE_SEARCH=true
# Google Analytics Measurement ID (не секрет)
VITE_GA_ID=G-XXXXXXXXXX
Почему важен:
- Новый разработчик копирует
.env.example→.env.localи заполняет значения. - Документирует, какие переменные существуют и что означают.
- Становится основой для валидации конфигурации.
Валидация конфигурации через zod
Ошибка конфигурации — «пустой бандл с белым экраном» из-за одной отсутствующей переменной. Валидация на старте решает это: приложение падает с понятным сообщением, а не молча ломается в runtime.
Для Next.js — next/env:
import { z } from 'zod';
const envSchema = z.object({
API_URL: z.string().url(),
GA_ID: z.string().optional(),
FEATURE_SEARCH: z.string().transform((v) => v === 'true'),
});
// next/env
export const env = createEnv({
server: {
DATABASE_URL: z.string().min(1),
},
client: {
NEXT_PUBLIC_API_URL: z.string().url(),
},
runtimeEnv: {
DATABASE_URL: process.env.DATABASE_URL,
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL,
},
});
Универсальный инструмент — @t3-oss/env-core:
import { createEnv } from '@t3-oss/env-core';
import { z } from 'zod';
export const env = createEnv({
server: { DATABASE_URL: z.string().min(1) },
clientPrefix: 'VITE_',
client: { VITE_API_URL: z.string().url() },
runtimeEnv: import.meta.env,
});
Плюсы: типизация env.* в IDE, отказ при невалидной конфигурации, явное разделение серверных и клиентских переменных.
Runtime-конфигурация в SPA
Статический SPA нельзя «перенастроить» после сборки — но можно подставить конфигурацию при отдаче HTML.
Вариант 1: подстановка на сервере
Сервер (nginx/Node) подставляет значения в index.html при каждом ответе:
<script>
window.env = {
API_URL: "https://api.example.com",
FEATURE_SEARCH: "true",
};
</script>
// приложение читает конфигурацию при старте
const apiUrl = window.env.API_URL;
Бандл собирается один раз, а значения задаются окружением сервера — тот же паттерн, что runtime-переменные в Docker.
Вариант 2: запрос к /api/config
Для SSR/гибридных приложений:
// сервер отдаёт конфигурацию отдельным эндпоинтом
GET /api/config → { API_URL, FEATURE_SEARCH }
// клиент
const config = await fetch('/api/config').then((r) => r.json());
Подходит, когда конфигурация объёмная или секретная (читается на сервере, а клиент получает только публичную часть).
Переменные в CI/CD и платформах деплоя
GitHub Actions
Секреты задаются в настройках репозитория и доступны в пайплайне:
- name: Build
run: npm run build
env:
NEXT_PUBLIC_API_URL: ${{ secrets.API_URL }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
Vercel / Netlify
Переменные задаются на уровне проекта и окружения (Production/Preview/Development), инжектятся при сборке. Preview и production получают свои значения автоматически.
# CLI
vercel env add NEXT_PUBLIC_API_URL production
vercel env add NEXT_PUBLIC_API_URL preview
Docker
Runtime-переменные через -e или compose environment (см. статью про Docker).
Лучшие практики
1. Всё конфигурируемое — в переменных окружения
Никаких захардкоженных URL и ключей в коде.
2. Разделяй build-time и runtime
Build-time — для того, что известно на сборке; runtime — для различий между окружениями.
3. Секреты — только в managed-хранилищах
Vercel/Netlify env, CI secrets, Vault. Не в бандл, не в репозиторий.
4. Коммить .env.example
Документируй переменные и их назначение.
5. Валидируй конфигурацию на старте
zod + next/env/@t3-oss/env — падай с понятной ошибкой, а не с белым экраном.
6. .env* в .gitignore
.env.local, .env.production, .env.*.local — никогда в git.
7. Отзывай утёкшие секреты
Утечка = перевыпуск, удаление файла из git не спасает.
Антипаттерны
1. Секрет в клиентском бандле
// ❌ Плохо: ключ увидят в DevTools
const stripeKey = import.meta.env.VITE_STRIPE_SECRET_KEY;
2. Захардкоженный URL
// ❌ Плохо: staging и prod — разный код
const API = 'https://api.production.example.com';
3. Отсутствие .gitignore для .env
# ❌ Плохо: .env с секретами уходит в репозиторий
4. Нет .env.example
Новый разработчик угадывает переменные по коду — минус часы на онбординг.
5. Нет валидации конфигурации
// ❌ Плохо: undefined в runtime вместо ошибки на старте
const api = env.API_URL; // undefined, если переменной нет
6. Секрет в логи
// ❌ Плохо: ключ утекает в логи CI/CD
console.log('token:', process.env.API_TOKEN);
Ключевые тезисы для интервью
- 12-factor: конфигурация хранится в переменных окружения, а не в коде.
- Build-time переменные (
VITE_*,NEXT_PUBLIC_*) вшиваются в бандл на сборке. - Runtime-переменные задаются при запуске и не требуют пересборки.
- В бандл попадают только публичные префиксы; секреты — никогда.
- Порядок приоритета:
.env.local>.env.[mode].local>.env.[mode]>.env. .env.localне коммитится;.env.example— коммитится как документация.- Секреты хранятся в платформах, CI-secrets, Vault — не в репозитории.
- Валидация конфигурации через zod (
next/env,@t3-oss/env) падает на старте с понятной ошибкой. - Runtime-конфигурация SPA — через
window.envили/api/config. - Утёкший секрет нельзя «удалить из git» — его перевыпускают.
Заключение
Конфигурация через переменные окружения — фундамент надёжных деплоев: один код, много окружений, никаких секретов в репозитории. Ключевые практики: разделение build-time/runtime, .env.example, валидация через zod и управляемые хранилища секретов.
Ключевое для Middle+ разработчика:
- Понимать, что и когда попадает в клиентский бандл.
- Уметь настраивать переменные для dev/staging/production.
- Правильно работать с секретами в CI/CD и на платформах деплоя.
- Валидировать конфигурацию на старте и документировать через
.env.example.