Перейти к содержимому

21. Переменные окружения

Представь: ты написал сервис и хочешь подключить базу данных. Пишешь прямо в коде: password: "super_secret_123". Коммитишь на GitHub. А там — публичный репозиторий… 💀

Именно для этого и существуют переменные окружения — секретные записки, которые хранятся вне кода!

Аналогия: Переменные окружения — как сейф в офисе. Ключи от сервера, пароли, API-токены лежат в сейфе (.env файл), а не на рабочем столе всех сотрудников (в коде). Каждый сотрудник знает, что “ключ от сейфа лежит в сейфе”, но сам ключ — секрет.

Что НЕЛЬЗЯ хардкодить в коде │ Что использовать вместо этого
──────────────────────────────────┼──────────────────────────────────────────
"password": "qwerty123" │ process.env.DB_PASSWORD
apiKey: "sk-abc123def456" │ process.env.OPENAI_API_KEY
host: "prod.db.example.com" │ process.env.DATABASE_URL
secret: "jwt-super-secret" │ process.env.JWT_SECRET
"https://api.staging.com" │ process.env.NEXT_PUBLIC_API_URL

Next.js поддерживает несколько .env файлов с разным приоритетом:

Приоритет загрузки (от высшего к низшему):
──────────────────────────────────────────────
1. .env.local ← ВСЕГДА загружается (кроме test), gitignore!
2. .env.[mode].local ← .env.development.local или .env.production.local
3. .env.[mode] ← .env.development или .env.production
4. .env ← базовый файл, для всех окружений
Пример:
.env → DATABASE_URL=postgres://localhost/mydb
.env.local → DATABASE_URL=postgres://localhost/mydb_local
Итого: DATABASE_URL = postgres://localhost/mydb_local (локальный приоритет!)
Окно терминала
NEXT_PUBLIC_APP_NAME="My Awesome App"
NEXT_PUBLIC_APP_VERSION="1.0.0"
NEXT_PUBLIC_SUPPORT_EMAIL="[email protected]"
# Значения по умолчанию (не секреты!)
DEFAULT_LOCALE="en"
MAX_FILE_SIZE_MB="10"
Окно терминала
# .env.local — СЕКРЕТЫ (в .gitignore! никогда не коммитить!)
DATABASE_URL="postgresql://user:password@localhost:5432/mydb"
JWT_SECRET="your-super-secret-jwt-key-here"
OPENAI_API_KEY="sk-proj-abc123..."
STRIPE_SECRET_KEY="sk_test_abc123..."
NEXTAUTH_SECRET="random-secret-32-chars-minimum"
NEXTAUTH_URL="http://localhost:3000"
Окно терминала
# .env.development — только для разработки
NEXT_PUBLIC_API_URL="http://localhost:3000/api"
NEXT_PUBLIC_DEBUG_MODE="true"
DATABASE_URL="postgresql://user:pass@localhost:5432/mydb_dev"
LOG_LEVEL="debug"
Окно терминала
# .env.production — только для продакшена
NEXT_PUBLIC_API_URL="https://api.example.com"
NEXT_PUBLIC_DEBUG_MODE="false"
LOG_LEVEL="error"
# DATABASE_URL берём из .env.local на сервере или из Vercel env vars
Окно терминала
# .env.test — для тестов (Jest, Playwright)
DATABASE_URL="postgresql://user:pass@localhost:5432/mydb_test"
NEXTAUTH_URL="http://localhost:3000"

⚠️ Важно: .env.local загружается ВСЕГДА (кроме тестов, где используется .env.test). Добавь его в .gitignore немедленно!


Это КРИТИЧЕСКИ важное различие! 🚨

// Серверные переменные (БЕЗОПАСНЫЕ) — только на сервере
const dbUrl = process.env.DATABASE_URL; // ✅ Только сервер
const jwtSecret = process.env.JWT_SECRET; // ✅ Только сервер
const apiKey = process.env.OPENAI_API_KEY; // ✅ Только сервер
// Публичные переменные (NEXT_PUBLIC_) — попадают в браузер!
const appUrl = process.env.NEXT_PUBLIC_API_URL; // ⚠️ Видно в браузере
const appName = process.env.NEXT_PUBLIC_APP_NAME; // ⚠️ Видно в браузере

Как это работает под капотом:

// Next.js при сборке делает буквальную замену строк!
// Твой код:
console.log(process.env.NEXT_PUBLIC_APP_NAME);
// Превращается в бандле в:
console.log("My Awesome App"); // ← значение зашито в JS файл!
// Поэтому открыв DevTools → Sources → main.js, ты увидишь это значение!
Что МОЖНО делать с NEXT_PUBLIC_ │ Что НЕЛЬЗЯ делать с NEXT_PUBLIC_
───────────────────────────────────┼──────────────────────────────────────────
URL публичного API │ Секретные ключи API
Название приложения │ Пароли базы данных
Версия приложения │ JWT секреты
ID аналитики (GA4) │ Stripe/Paypal секретные ключи
Feature flags (публичные) │ Ключи шифрования
Публичный ключ Stripe (pk_*) │ OAuth client secrets
// ✅ Правильно — публичный ключ Stripe (pk_ — Public Key)
const stripe = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!);
// ❌ ОПАСНО! Секретный ключ никогда не должен быть NEXT_PUBLIC_!
// process.env.NEXT_PUBLIC_STRIPE_SECRET_KEY ← НИКОГДА!

// app/page.tsx — Server Component
export default async function HomePage() {
// Серверные переменные доступны напрямую
const apiUrl = process.env.INTERNAL_API_URL; // undefined в браузере
const data = await fetch(`${apiUrl}/products`);
return <div>...</div>;
}
// components/Analytics.tsx — Client Component
'use client';
import { useEffect } from 'react';
export function Analytics() {
useEffect(() => {
// Только NEXT_PUBLIC_ доступны в клиентских компонентах
const gaId = process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID;
if (gaId) {
// Инициализация Google Analytics
window.gtag('config', gaId);
}
}, []);
return null;
}
// app/api/chat/route.ts — API Route (только сервер)
import OpenAI from 'openai';
import { NextRequest, NextResponse } from 'next/server';
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY, // Безопасно — только сервер!
});
export async function POST(req: NextRequest) {
const { message } = await req.json();
const response = await openai.chat.completions.create({
model: 'gpt-4o',
messages: [{ role: 'user', content: message }],
});
return NextResponse.json({ reply: response.choices[0].message.content });
}

🛡️ Валидация с Zod — делаем это правильно

Заголовок раздела «🛡️ Валидация с Zod — делаем это правильно»

Просто использовать process.env.SOMETHING опасно. Что если переменная не задана? Приложение сломается в рантайме с непонятной ошибкой.

Решение: валидируем все переменные при старте приложения!

Окно терминала
npm install zod
// env.ts — центральный файл валидации
import { z } from 'zod';
// Схема для серверных переменных
const serverSchema = z.object({
// База данных
DATABASE_URL: z.string().url('DATABASE_URL должен быть валидным URL'),
// Аутентификация
NEXTAUTH_SECRET: z.string().min(32, 'NEXTAUTH_SECRET минимум 32 символа'),
NEXTAUTH_URL: z.string().url(),
// Внешние сервисы
OPENAI_API_KEY: z.string().startsWith('sk-', 'Некорректный OpenAI API key'),
STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
// Опциональные
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
MAX_FILE_SIZE_MB: z.coerce.number().positive().default(10),
// Окружение
NODE_ENV: z.enum(['development', 'production', 'test']),
});
// Схема для публичных переменных (клиент + сервер)
const clientSchema = z.object({
NEXT_PUBLIC_APP_NAME: z.string().min(1),
NEXT_PUBLIC_API_URL: z.string().url(),
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: z.string().startsWith('pk_'),
NEXT_PUBLIC_GA_MEASUREMENT_ID: z.string().optional(),
});
// Валидируем при импорте модуля
function validateEnv() {
const serverParsed = serverSchema.safeParse(process.env);
if (!serverParsed.success) {
console.error('❌ Ошибка валидации серверных env переменных:');
console.error(serverParsed.error.flatten().fieldErrors);
throw new Error('Невалидные переменные окружения');
}
const clientParsed = clientSchema.safeParse(process.env);
if (!clientParsed.success) {
console.error('❌ Ошибка валидации публичных env переменных:');
console.error(clientParsed.error.flatten().fieldErrors);
throw new Error('Невалидные публичные переменные окружения');
}
return {
server: serverParsed.data,
client: clientParsed.data,
};
}
export const env = validateEnv();
// Теперь env.server.DATABASE_URL — строго типизирован и гарантированно существует!
// env.client.NEXT_PUBLIC_APP_NAME — тоже!
// Использование в коде
import { env } from '@/env';
// ✅ Строго типизировано, автодополнение работает!
const dbUrl = env.server.DATABASE_URL; // string
const appName = env.client.NEXT_PUBLIC_APP_NAME; // string
const logLevel = env.server.LOG_LEVEL; // "debug" | "info" | "warn" | "error"

T3 Stack — популярный стек от Theo. Они создали библиотеку специально для типобезопасных env переменных в Next.js:

Окно терминала
npm install @t3-oss/env-nextjs zod
// env.ts — с @t3-oss/env-nextjs
import { createEnv } from '@t3-oss/env-nextjs';
import { z } from 'zod';
export const env = createEnv({
/**
* Серверные переменные — только Node.js
* НЕ попадают в браузер
*/
server: {
DATABASE_URL: z.string().url(),
NEXTAUTH_SECRET: z.string().min(32),
NEXTAUTH_URL: z.preprocess(
(str) => process.env.VERCEL_URL ?? str,
process.env.VERCEL ? z.string() : z.string().url()
),
OPENAI_API_KEY: z.string().startsWith('sk-'),
STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
NODE_ENV: z.enum(['development', 'production', 'test']),
},
/**
* Клиентские переменные — попадают в браузер
* ОБЯЗАТЕЛЬНО должны начинаться с NEXT_PUBLIC_
*/
client: {
NEXT_PUBLIC_APP_URL: z.string().url(),
NEXT_PUBLIC_APP_NAME: z.string().min(1),
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: z.string().startsWith('pk_'),
},
/**
* Маппинг переменных (нужен для проверки)
* Убеждаемся что переменные действительно берутся из process.env
*/
runtimeEnv: {
DATABASE_URL: process.env.DATABASE_URL,
NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
NEXTAUTH_URL: process.env.NEXTAUTH_URL,
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
NODE_ENV: process.env.NODE_ENV,
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
NEXT_PUBLIC_APP_NAME: process.env.NEXT_PUBLIC_APP_NAME,
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY:
process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY,
},
/**
* По умолчанию @t3-oss/env-nextjs выдаёт ошибку если
* серверные переменные используются в клиентском коде.
* skipValidation=true для тестов или CI без полных env
*/
skipValidation: !!process.env.SKIP_ENV_VALIDATION,
});
// Использование — максимально удобно!
import { env } from '@/env';
// Сервер
const client = new OpenAI({ apiKey: env.OPENAI_API_KEY });
await db.connect(env.DATABASE_URL);
// Клиент
const stripe = loadStripe(env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY);
document.title = env.NEXT_PUBLIC_APP_NAME;

Окно терминала
# Через Vercel CLI
vercel env add DATABASE_URL production
vercel env add JWT_SECRET production
# Просмотр
vercel env ls
# Удаление
vercel env rm DATABASE_URL production

В Vercel Dashboard: Settings → Environment Variables. Можно задавать разные значения для Preview/Development/Production.

Vercel окружения:
──────────────────────────────────────────
Production ← Только main ветка
Preview ← Pull Request ветки
Development ← vercel dev (локально)
.github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
env:
# Берём секреты из GitHub Secrets
DATABASE_URL: ${{ secrets.DATABASE_URL }}
NEXTAUTH_SECRET: ${{ secrets.NEXTAUTH_SECRET }}
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
env:
NEXT_PUBLIC_API_URL: ${{ vars.NEXT_PUBLIC_API_URL }}
Окно терминала
# Через GitHub CLI
gh secret set DATABASE_URL
gh secret set NEXTAUTH_SECRET --body "$(openssl rand -base64 32)"
gh secret list

Это тонкий, но важный момент! 🎯

Build-time (при сборке) │ Runtime (при запуске)
─────────────────────────────────────┼─────────────────────────────────────────
NEXT_PUBLIC_* переменные │ Серверные переменные (без NEXT_PUBLIC_)
Зашиваются в JS бандл │ Читаются из process.env при запросе
Меняются ТОЛЬКО при пересборке │ Можно менять без пересборки
Одинаковы для всех пользователей │ Могут быть разными на разных серверах
// ⚠️ Ловушка! NEXT_PUBLIC_ — build-time!
// Если ты изменишь NEXT_PUBLIC_API_URL на сервере без пересборки — ничего не изменится!
// Значение уже зашито в бандл.
// Решение: для динамических публичных значений используй API endpoint
// app/api/config/route.ts
export async function GET() {
return Response.json({
// Эти значения читаются в РАНТАЙМЕ каждый раз!
featureFlags: {
newCheckout: process.env.FEATURE_NEW_CHECKOUT === 'true',
darkMode: process.env.FEATURE_DARK_MODE === 'true',
},
});
}
// next.config.mjs — можно задавать env прямо в конфиге
/** @type {import('next').NextConfig} */
const nextConfig = {
env: {
// Это значение будет доступно как process.env.BUILD_TIME
// и зашито в бандл при сборке
BUILD_TIME: new Date().toISOString(),
APP_VERSION: process.env.npm_package_version,
},
};
export default nextConfig;

# Dockerfile — правильный подход
FROM node:20-alpine AS base
# --- Зависимости ---
FROM base AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci
# --- Сборка ---
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# NEXT_PUBLIC_ нужны при сборке!
# Передаём через build args
ARG NEXT_PUBLIC_API_URL
ARG NEXT_PUBLIC_APP_NAME
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_APP_NAME=$NEXT_PUBLIC_APP_NAME
# Серверные секреты НЕ должны быть в образе!
# Они будут переданы при запуске контейнера
RUN npm run build
# --- Production ---
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
EXPOSE 3000
CMD ["node", "server.js"]
docker-compose.yml
version: '3.8'
services:
app:
build:
context: .
args:
# Build-time переменные (NEXT_PUBLIC_)
NEXT_PUBLIC_API_URL: https://api.example.com
NEXT_PUBLIC_APP_NAME: My App
ports:
- "3000:3000"
environment:
# Runtime переменные (серверные секреты)
# НЕ зашиваются в образ, передаются при запуске
DATABASE_URL: ${DATABASE_URL}
JWT_SECRET: ${JWT_SECRET}
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET}
env_file:
- .env.production.local # Или через secrets в swarm/k8s
Окно терминала
# Запуск с переменными
docker run \
-e DATABASE_URL="postgresql://..." \
-e JWT_SECRET="secret" \
-p 3000:3000 \
my-next-app
# Или через файл
docker run --env-file .env.production -p 3000:3000 my-next-app

Ошибка 1: Случайно выставить секрет через NEXT_PUBLIC_

Заголовок раздела «Ошибка 1: Случайно выставить секрет через NEXT_PUBLIC_»
Окно терминала
# ❌ КАТАСТРОФА! Stripe секретный ключ в браузере!
NEXT_PUBLIC_STRIPE_SECRET_KEY=sk_live_abc123...
# ✅ Правильно — секретный ключ только на сервере
STRIPE_SECRET_KEY=sk_live_abc123...
# А публичный ключ — через NEXT_PUBLIC_
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_xyz789...
// ❌ Не работает в Client Components!
'use client';
const dbUrl = process.env.DATABASE_URL; // undefined в браузере!
// ✅ Только NEXT_PUBLIC_ в клиентском коде
const apiUrl = process.env.NEXT_PUBLIC_API_URL; // работает!
// ✅ Или передавай через props из Server Component
// Server Component:
export default function Layout() {
return <ClientComp config={{ apiUrl: process.env.INTERNAL_API_URL }} />;
}
Окно терминала
# ❌ Новый разработчик клонирует проект и не знает какие переменные нужны
# ✅ Создавай .env.example (коммитить в git! без реальных значений)
Окно терминала
# .env.example — документация для команды (коммитить!)
DATABASE_URL="postgresql://user:password@localhost:5432/mydb"
NEXTAUTH_SECRET="generate-with-openssl-rand-base64-32"
NEXTAUTH_URL="http://localhost:3000"
OPENAI_API_KEY="sk-..."
STRIPE_SECRET_KEY="sk_test_..."
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY="pk_test_..."
NEXT_PUBLIC_APP_URL="http://localhost:3000"
NEXT_PUBLIC_APP_NAME="My App"
# .gitignore — убедись что это есть!
.env
.env.local
.env*.local
# НО: .env.example и .env.development (без секретов) — можно коммитить
// ❌ Опечатка — получим undefined молча
const key = process.env.OPNEAI_API_KEY; // Typo! OPNEAI вместо OPENAI
// ✅ С zod валидацией сразу увидишь ошибку при старте!
// ❌ Ошибка: "OPENAI_API_KEY" is required but received undefined

Отличная практика — держать .env.example актуальным с комментариями:

Окно терминала
# .env.example — копируй в .env.local и заполняй реальными значениями
# Генерация: cp .env.example .env.local
# ═══════════════════════════════════════
# БАЗА ДАННЫХ
# ═══════════════════════════════════════
# PostgreSQL URL. Для локальной разработки: docker-compose up -d db
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/myapp_dev"
# ═══════════════════════════════════════
# АУТЕНТИФИКАЦИЯ (NextAuth.js)
# ═══════════════════════════════════════
# Генерация: openssl rand -base64 32
NEXTAUTH_SECRET=""
# URL приложения. В продакшене: https://your-domain.com
NEXTAUTH_URL="http://localhost:3000"
# GitHub OAuth (создай на github.com/settings/apps)
GITHUB_CLIENT_ID=""
GITHUB_CLIENT_SECRET=""
# ═══════════════════════════════════════
# ПЛАТЕЖИ (Stripe)
# ═══════════════════════════════════════
# Тестовые ключи: dashboard.stripe.com/test/apikeys
STRIPE_SECRET_KEY="sk_test_..."
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY="pk_test_..."
STRIPE_WEBHOOK_SECRET="whsec_..."
# ═══════════════════════════════════════
# AI (OpenAI)
# ═══════════════════════════════════════
# Получи на: platform.openai.com/api-keys
OPENAI_API_KEY="sk-proj-..."
# ═══════════════════════════════════════
# ПУБЛИЧНЫЕ ПЕРЕМЕННЫЕ (видны в браузере!)
# ═══════════════════════════════════════
NEXT_PUBLIC_APP_NAME="My Awesome App"
NEXT_PUBLIC_APP_URL="http://localhost:3000"
NEXT_PUBLIC_GA_MEASUREMENT_ID="G-XXXXXXXXXX"

⚙️ next.config.mjs — загрузка и переопределение

Заголовок раздела «⚙️ next.config.mjs — загрузка и переопределение»
next.config.mjs
import { fileURLToPath } from 'node:url';
import path from 'node:path';
/** @type {import('next').NextConfig} */
const nextConfig = {
// Добавить переменные окружения в бандл
env: {
CUSTOM_KEY: 'my-value',
BUILD_TIMESTAMP: new Date().toISOString(),
},
// Rewrites используют env переменные
async rewrites() {
return [
{
source: '/api/proxy/:path*',
destination: `${process.env.BACKEND_URL}/:path*`,
},
];
},
// Проверка переменных при сборке
webpack(config) {
const required = ['DATABASE_URL', 'NEXTAUTH_SECRET'];
const missing = required.filter((key) => !process.env[key]);
if (missing.length > 0 && process.env.NODE_ENV === 'production') {
throw new Error(
`Отсутствуют обязательные env переменные: ${missing.join(', ')}`
);
}
return config;
},
};
export default nextConfig;

Окно терминала
# Генерация NEXTAUTH_SECRET (32 байта = 64 hex символа)
openssl rand -base64 32
# Или через Node.js
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
# JWT Secret
node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"
# Для быстрой настройки используй Vercel CLI
vercel env pull .env.local
# Скачивает переменные из Vercel проекта в .env.local

Файл │ Gitignore? │ Когда загружается
────────────────────┼────────────┼──────────────────────────────────────
.env │ Нет │ Всегда
.env.local │ Да! │ Всегда (кроме test)
.env.development │ Нет │ next dev
.env.production │ Нет │ next build + next start
.env.test │ Нет │ jest, playwright
.env.*.local │ Да! │ Соответствующий режим
.env.example │ Нет │ Никогда (только документация)
Переменная │ Server │ Client │ Пример использования
────────────────────────┼────────┼────────┼──────────────────────────────
DATABASE_URL │ ✅ │ ❌ │ Подключение к БД
JWT_SECRET │ ✅ │ ❌ │ Подпись токенов
OPENAI_API_KEY │ ✅ │ ❌ │ OpenAI запросы
NEXT_PUBLIC_APP_NAME │ ✅ │ ✅ │ Название в заголовке
NEXT_PUBLIC_GA_ID │ ✅ │ ✅ │ Google Analytics
NEXT_PUBLIC_API_URL │ ✅ │ ✅ │ URL публичного API

✅ .env.example создан и актуален (с комментариями)
✅ .env.local в .gitignore
✅ Все секреты без NEXT_PUBLIC_ префикса
✅ Валидация через zod или @t3-oss/env-nextjs
✅ Переменные добавлены в Vercel/CI/CD
✅ .env.test для тестовых значений
✅ Docker build args для NEXT_PUBLIC_ переменных
✅ Нет секретов в git истории (проверь!)
✅ NEXTAUTH_SECRET сгенерирован безопасно
✅ Нет захардкоженных URL, ключей, паролей в коде

Теперь твои секреты — под надёжной охраной! 🔐🛡️