Переменные окружения
Полный список с рабочими локальными значениями — .env.example в корне. Здесь разбор по
группам: что обязательно, что обязательно сменить перед публикацией наружу.
Два файла, а не один
| Файл | Кто читает |
|---|---|
.env в корне | Docker Compose и все сервисы стека |
apps/backend/.env | Medusa CLI с хоста (db:migrate, medusa user) |
Значения в них должны совпадать — как минимум DATABASE_URL и MEDUSA_FF_RBAC. Расхождение
даёт падения, которые выглядят как что угодно, только не как ошибка конфигурации.
Секреты: сменить обязательно
Значения из .env.example рабочие только локально. Перед выкладкой наружу генерируйте
независимые случайные значения (openssl rand -base64 32) для каждого окружения:
JWT_SECRET, COOKIE_SECRET, AUTH_MFA_ENCRYPTION_KEY, MOYSKLAD_ENCRYPTION_KEY,
PAYMENT_PROVIDER_CONFIG_ENCRYPTION_KEY, SHIPPING_PROVIDER_CONFIG_ENCRYPTION_KEY,
POSTGRES_PASSWORD, MINIO_ROOT_PASSWORD, S3-ключи, все STRAPI_* секреты,
MEILI_MASTER_KEY, ADMIN_PASSWORD.
COOKIE_SECRET подписывает cookie-сессии Medusa — независимо от JWT_SECRET, который отвечает
за токены аутентификации (medusa-config.ts:30). Ротация одного не затрагивает другой; смена
COOKIE_SECRET разлогинивает всех, у кого была активная сессионная cookie, а смена JWT_SECRET
инвалидирует выданные JWT.
MOYSKLAD_ENCRYPTION_KEY, PAYMENT_PROVIDER_CONFIG_ENCRYPTION_KEY и
SHIPPING_PROVIDER_CONFIG_ENCRYPTION_KEY шифруют реквизиты интеграций, уже лежащие в базе.
Смена ключа делает сохранённые реквизиты нечитаемыми — их придётся вводить заново.
Адреса и CORS
MEDUSA_BACKEND_URL, STOREFRONT_URL, STOREFRONT_PUBLIC_URL, STORE_CORS, ADMIN_CORS,
AUTH_CORS.
STORE_CORS сверяет строку точно: http://localhost:8000 и http://127.0.0.1:8000 — разные
источники. ADMIN_CORS и AUTH_CORS управляют доступом независимо друг от друга — первый
разрешает источники для Admin API, второй отдельно для auth-роутов (/auth/*); задать один без
другого — обычная причина, по которой браузер пропускает запросы к админке, но блокирует логин
(или наоборот).
VITE_BACKEND_URL — отдельный build/runtime-параметр именно кастомных Admin API-расширений
(admin/lib/client.ts:4, sdk.ts:4), не связан с MEDUSA_INTERNAL_URL витрины. Не задан —
используется текущий origin страницы (относительный путь /).
MEDUSA_INTERNAL_URL — отдельная переменная, не MEDUSA_BACKEND_URLВитрина проксирует /store, /auth и /catalog/* на backend через middleware, а не через
rewrites() (адрес backend известен только в рантайме, а rewrites() вычисляется на этапе
сборки). Без MEDUSA_INTERNAL_URL эти пути остаются непроксированными вместо обращения к
backend (middleware.ts:26), а серверные API-вызовы отдельно требуют абсолютный HTTP(S)-адрес
в этой же переменной — иное значение или отсутствие переменной там даёт явную ошибку
(lib/api/base-url.ts:11). Next.js-витрина MEDUSA_BACKEND_URL не использует вовсе — эту
переменную читают только сторонние по отношению к витрине инструменты: бенчмарк поиска
(apps/backend/src/scripts/benchmark-catalog-search.ts) и e2e-обвязка
(apps/e2e/playwright.config.ts, apps/e2e/scripts/visual-check.ts) для выбора адреса
проверяемого backend; спутать её с MEDUSA_INTERNAL_URL даёт «backend вроде настроен, а Store
API не отвечает».
Флаги поведения
| Переменная | Смысл |
|---|---|
MEDUSA_FF_RBAC | Роли и права в админке. Должна быть true в обоих env-файлах |
SESSION_COOKIE_SECURE | false только пока перед стеком нет TLS. Появился HTTPS-прокси — ставьте true |
MEDUSA_ADMIN_ONBOARDING_TYPE | skip — не показывать мастер первого запуска |
YOOKASSA_WEBHOOK_TRUSTED_PROXY_CIDRS | CIDR прокси, которым разрешено подставлять X-Forwarded-For в вебхуках ЮKassa. Пусто, если backend смотрит наружу напрямую |
SESSION_COOKIE_SECURE=false — осознанный компромиссMedusa форсирует Secure для сессионной cookie покупателя при NODE_ENV=production. Без TLS
браузер такую cookie не сохранит, и вход в личный кабинет молча сломается. Compose сам TLS не
терминирует, поэтому по умолчанию false. Как только появился nginx/Caddy/облачный балансировщик
с HTTPS — переключите на true.
NODE_ENV=test отключает часть модулей
При NODE_ENV=test отключаются ApiShip-плагин, S3, Redis cache/event/workflow engine,
Meilisearch и fulfillment-провайдеры (medusa-config.ts:44) — большинство тестов идёт на
in-memory-заменах. rbac — исключение, он вынесен из-под isTest-ветки и не выключается
тестовым окружением: иначе checkPermissions 403-ил бы в тестах, поскольку роуты
policy-wrap'аются по тому же флагу независимо от того, загружен ли сам модуль (детали — раздел
«Архитектура» в CLAUDE.md проекта).
Практическое следствие: зелёные unit- и integration-тесты не проверяют реальные Redis/S3/
Meilisearch/ApiShip — это осознанный компромисс скорости тестов, а не пробел покрытия, который
нужно закрывать. Живые прогоны против настоящих сервисов — отдельная задача (см. «Тесты — только
на моках» в CLAUDE.md).
Тестовая почта: E2E_RUN
E2E_RUN=true подменяет email-провайдер на локальный interception endpoint вместо реального SMTP
(medusa-config.ts:166) — письма в тестах никуда не улетают наружу. При включённом флаге
обязателен E2E_NOTIFICATION_STUB_URL (medusa-config.ts:6) — адрес заглушки. Сама отправка в
эту заглушку прерывается через 5 секунд (modules/notification-e2e/service.ts:54).
Event bus на Redis
Упавшее событие повторяется до 5 раз с экспоненциальной задержкой (база 5 секунд), ошибка
хранится до 7 дней (medusa-config.ts:121). Это касается подписчиков (subscribers/*) —
если обработчик события бросает исключение, Medusa сам поставит повтор по этому расписанию, без
собственного retry-кода в подписчике.
Некоторые подписчики намеренно логируют ошибку и подавляют её вместо того, чтобы бросить
исключение — например письмо сброса пароля (subscribers/send-password-reset-email.ts) или
уведомление о возврате (subscribers/shipping-return-notifications.ts:40). Такие ошибки НЕ
попадают в event-bus retry выше — это решение конкретного подписчика, а не общее поведение.
Первый администратор
ADMIN_EMAIL / ADMIN_PASSWORD — из них контейнер backend-init создаёт администратора при
первом docker compose up. Значения по умолчанию (admin@test.com / supersecret) публичны:
это дефолт из репозитория, а не пароль. Меняйте до того, как стенд станет доступен снаружи.
Почта
SMTP_HOST, SMTP_PORT (по умолчанию 587), SMTP_USER, SMTP_PASSWORD, SMTP_FROM
(modules/notification-smtp/service.ts:42,193). Порт 465 автоматически включает
secure-режим; авторизация подключается только если задан SMTP_USER. Отсутствующие
host/from не мешают загрузке приложения — ошибка возникает только при попытке отправки
(:32). Вложения Medusa Notification передаются в nodemailer вместе с текстовой и
HTML-версией письма (:68).
Ошибки отправки логируются и глотаются: недоступный SMTP никогда не ломает сценарий, который письмо породил. Обратная сторона — молчаливое отсутствие писем выглядит как «всё хорошо».
Ссылка сброса пароля заявляет срок действия 15 минут; сбой SMTP при её отправке не ставится
в event-bus retry (subscribers/send-password-reset-email.ts). При отсутствии STOREFRONT_URL
ссылка отписки от напоминаний о брошенной корзине строится с резервным адресом
http://localhost:8000 (lib/abandoned-cart-links.ts:3) — на продакшене без заданного
STOREFRONT_URL эта ссылка будет вести не туда.
Хранилище и поиск
S3/MinIO: S3_BUCKET, S3_FILE_URL, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY,
S3_REGION, S3_FORCE_PATH_STYLE. У Strapi отдельный бакет и отдельные ключи
(STRAPI_S3_*) с доступом только к нему — так загрузки редактора не могут задеть изображения
товаров. STRAPI_S3_PUBLIC_URL, STRAPI_S3_ROOT_PATH (по умолчанию media),
STRAPI_S3_FORCE_PATH_STYLE (по умолчанию true, в отличие от S3_FORCE_PATH_STYLE у Medusa)
задают адресацию отдельно от бакета товаров (apps/cms/config/plugins.ts).
Strapi принимает только image/jpeg, image/png, image/webp, image/avif — исполняемые и
прочие форматы отклоняются на уровне allowlist. STRAPI_UPLOAD_SIZE_LIMIT ограничивает размер
файла, по умолчанию 8 МиБ (plugins.ts:55). Объекты загружаются с ACL public-read.
S3_FORCE_PATH_STYLE=true переключает адресацию объектов с виртуального хоста
(bucket.s3.amazonaws.com/key) на path-style (s3.amazonaws.com/bucket/key,
medusa-config.ts:111) — MinIO по умолчанию понимает только path-style, поэтому в
docker-compose-стенде эта переменная обязательна; для настоящего AWS S3 её обычно не задают.
Meilisearch: MEILI_MASTER_KEY, MEILISEARCH_HOST, MEILISEARCH_API_KEY.
Связь с CMS
STRAPI_INTERNAL_URL, STRAPI_READ_TOKEN, STRAPI_READ_TOKEN_FILE, CMS_BOOTSTRAP_TOKEN_DIR, STRAPI_WEBHOOK_SECRET,
CMS_PREVIEW_INTERNAL_SECRET, CMS_PREVIEW_SIGNING_SECRET — см.
Контент из Strapi на витрину.
STRAPI_READ_TOKEN необязателен: это совместимое ручное переопределение для уже настроенных
окружений. Если он пуст, backend читает токен из STRAPI_READ_TOKEN_FILE (по умолчанию
/run/cms-bootstrap/strapi-read-token), который создаёт одноразовый cms-bootstrap. В Compose
путь строится из общего CMS_BOOTSTRAP_TOKEN_DIR, поэтому bootstrap и backend всегда используют
один и тот же том; не задавайте STRAPI_READ_TOKEN_FILE там отдельно. Для запуска вне Compose
можно задать STRAPI_READ_TOKEN_FILE напрямую.
COMPANY_NAME используется только при первом создании Site settings; при отсутствии берётся
«Новый магазин». Существующие записи CMS, в том числе черновики, не перезаписываются.
Собственная конфигурация Strapi (apps/cms/config)
Отдельный набор переменных, независимый от списков выше — читается только процессом CMS:
HOST/PORT— по умолчанию0.0.0.0/1337(config/server.ts).APP_KEYS— обязателен, список ключей сессии админки Strapi (без дефолта,env.array).- Секреты Strapi разделены по назначению, каждый обязателен без дефолта (
config/admin.ts):ADMIN_JWT_SECRET(сессии админки),API_TOKEN_SALT(соль для API-токенов, тот же механизм, что создаёт read-токен для backend),TRANSFER_TOKEN_SALT,ENCRYPTION_KEY. STRAPI_JWT_SECRET— отдельный секрет, не путать сADMIN_JWT_SECRETвыше: передаётся CMS какJWT_SECRETплагинаusers-permissions(docker-compose.yml:267, обязателен) — подписывает токены пользователей плагина, а не сессии Strapi-админки.DATABASE_CLIENT—postgres/mysql/sqlite.WEBHOOKS_POPULATE_RELATIONS(config/server.ts:10, по умолчанию выключено) — включает заполнение relations в payload штатных Strapi-вебхуков (не путать с кастомным вебхуком публикации, см. Контент из Strapi, у него свой формат).FLAG_NPS,FLAG_PROMOTE_EE,FLAG_DOC_LINKS(config/admin.ts:20, по умолчанию все включены) — служебные UI-элементы Strapi Admin (опрос удовлетворённости, промо Enterprise, ссылки на документацию), не влияют на бизнес-логику.
(*) Пустой DATABASE_CLIENT тихо переключает Strapi на SQLiteПо умолчанию DATABASE_CLIENT равен sqlite, а не postgres (config/database.ts:7) — файл
.tmp/data.db внутри контейнера. Ошибочно не заданная переменная не роняет запуск: Strapi просто
поднимется на локальной SQLite вместо ожидаемой PostgreSQL, и весь контент будет жить в
эфемерном файле контейнера, а не в БД cms-db-init (см. Docker Compose).
При DATABASE_CLIENT=postgres дополнительно доступны DATABASE_SCHEMA (по умолчанию public),
DATABASE_SSL* (по умолчанию выключен) и DATABASE_POOL_MIN/DATABASE_POOL_MAX (по умолчанию
2/10).
Плагин users-permissions (config/plugins.ts:21) настроен на jwtManagement: 'refresh' — токен
пользователя обновляется по refresh-механизму, а не выдаётся один раз надолго, и сама
refresh-сессия хранится в HttpOnly-cookie (sessions.httpOnly: true), недоступной клиентскому
JS.
CSP CMS (config/middlewares.ts) строит connect-src/img-src/media-src на основе origin из
STRAPI_S3_PUBLIC_URL — незаданная или неверная переменная блокирует загрузку медиа из MinIO
браузером, даже если сама CMS их отдаёт. upgradeInsecureRequests отключён — значимо для
локального HTTP-контура без TLS-прокси.
Чего в переменных нет
Реквизиты МойСклад, ЮKassa, ApiShip и СДЭК в .env не хранятся. Их вводят в админке, они
шифруются и лежат в базе. В окружении есть только ключи шифрования.