Старт приложения: падать сразу, а не в проде
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Сервер, который запустился, ещё ничего не доказал. Переменная окружения, не доехавшая до контейнера, не мешает ему слушать порт и отвечать на /health: она всплывёт при первом письме, первом входе или через неделю, когда выкат давно признан удачным. Эта статья — о первых миллисекундах процесса. Разобрать окружение один раз, упасть со списком всех причин до того, как открыт порт, и проверить две настройки, которые чаще других ошибаются в сторону «пустить всех», — CORS и опубликованные порты.
Окружение процесса — набор пар «имя=значение», который операционная система отдаёт программе при запуске. Значения в нём всегда строки: чисел, булевых и списков у системы нет. Файл .env этого не меняет — он только способ заполнить тот же набор, и Node кладёт прочитанное в process.env строками. В типах Node это записано честно: process.env.PORT имеет тип string | undefined.
В части IV окружение уже встречалось: на сборке Next переменные с NEXT_PUBLIC_ превращались в литералы, а серверные оставались обращениями к process.env и читались в момент запроса. Вот это «в момент запроса» и есть беда. Ниже окружение в том виде, в каком его получит сервер из наполовину заполненного файла, и четыре строки, которые легко написать не задумываясь. В песочнице process нет, поэтому окружение передано объектом того же типа. Прогноз: что напечатает каждая строка?
Ни одна строка не упала — все четыре дали значение, и все четыре неверны. PORT= в файле — пустая строка, а Number("") — ноль. Ноль в listen означает «любой свободный порт»: сервер стартует и пишет в лог, что слушает, а прокси стучится в порт из своей настройки и получает отказ. Boolean("false") — true, как у любой непустой строки: почтовый клиент будет ждать TLS от сервера, про который явно написали false. "3" + 1 — склейка, а не сложение. А ?? подставляет умолчание только вместо undefined и null: строка DATABASE_URL= без значения даёт пустую строку, та проходит мимо, и адресом базы становится "".
Компилятор заметил одну строку из четырёх, и то не главное в ней: он предупредил, что RETRIES может оказаться undefined, а сложение строки с числом его устраивает. Остальное для типов законно: Number принимает строку, Boolean — что угодно.
Всех четырёх объединяет место, где они проявятся: там, где значение прочитали, — в обработчике входа, в отправке письма, в первом запросе к базе. Лекарство то же, что для ответа чужого сервера в части III, — разбор вместо проверок по месту. Окружение разбирается один раз, при старте, в объект с настоящими типами, а остальной код видит только этот объект.
Правило для кода сервера: process.env читает один модуль конфигурации. Имя переменной, найденное поиском в обработчике запроса, — место, которое проверка на старте не видит.
Разбор окружения отличается от чтения настроек из хранилища, где мусор спокойно заменяли умолчанием: здесь неверные данные — не повод подставить своё и работать дальше. Без адреса базы и секрета подписи сервер не должен начинать работу вовсе. Но и бросать исключение на первой ошибке невыгодно. Каждая попытка выката — сборка, доставка и перезапуск, минуты на круг. Если на сервере не заполнены три переменные, исключение покажет их за три круга, а список — за один.
Отдельная причина отказа — плейсхолдер. В .env.example лежат заглушки вроде JWT_SECRET=dev-secret-change-me: с ними проект заводится на ноутбуке одной командой копирования. Но пример лежит в репозитории. Сервер, который подписывает токены такой заглушкой, подписывает их секретом, известным каждому, кто видел репозиторий. Что на самом деле доказывает подпись, разбирает «Собираем токен: три части, подпись, срок, подделка»; а такой же список заглушек стоит в main.ts боевого API автора.
Функция ниже проверяет четыре переменные и возвращает либо готовый Config, либо все причины отказа. Первый набор — .env.example, скопированный на сервер и заполненный не до конца, второй — заполненный. Прогноз: сколько причин найдётся в первом наборе и какие?
Три причины за один вызов — и порта среди них нет: "8000" честно разбирается в число. Обратите внимание на JSON.stringify в тексте причины. Пустую строку он печатает как "", а отсутствующую переменную — как undefined, и это две разные поломки: первая — строка в файле без значения, вторая — опечатка в имени или файл, который не доехал до сервера. Чинят их в разных местах, и текст причины сразу говорит, где искать.
Секрет проверяется двумя правилами, и нужны оба. Список заглушек ловит самый частый случай — скопированный пример — и называет его прямо. Длина ловит остальное: secret123 ни в каком списке нет, а перебирается быстро. 32 символа — нижняя граница, а не рекомендация: openssl rand -hex 32 выдаёт 64.
Для порта умолчание допустимо: сервер, который без переменной слушает 8000, ничего не скрывает, и checkConfig могла бы подставить его вместо отказа. Для адреса базы и секрета — нет. Умолчание postgres://localhost:5432/dev на сервере означает, что при забытой переменной приложение стартует и пойдёт в базу, которой там нет, — или в ту, что есть, но чужая. Умолчание для секрета — та же заглушка, только переехавшая из файла в код. Правило простое: умолчание годится там, где безопасно любое его значение.
На ноутбуке заглушка в .env — нормальное дело: секрет разработки никого не защищает, и заставлять каждого генерировать его ради первого запуска незачем. Поэтому проверку секрета часто включают условием. Условие читает то же самое окружение, и у него свой вопрос: что будет, если переменной, от которой оно зависит, нет?
Ниже одно и то же правило про заглушку, включённое двумя способами: «только когда NODE_ENV равен production» и «всегда, кроме NODE_ENV=development». Прогноз: где два способа разойдутся?
На ноутбуке и на правильно настроенном сервере способы ведут себя одинаково — поэтому разницу и не замечают. Расходятся они ровно в той поломке, от которой проверка должна защищать: окружение не доехало целиком. Условие «только в production» решает, что перед ним разработка, и выключается само. Условие «кроме development» остаётся строгим: послабление требует явного слова, а отсутствие слова означает строгость.
У боевого API автора NODE_ENV: production вписан прямо в compose-файл, а не в .env на сервере, поэтому его проверка «только в production» не выключится вместе с файлом. Но это свойство конкретного compose, а не кода: перенесите сервис в другую обвязку, и защита пропадёт без единой строки в диффе.
Теперь та же проверка в настоящем процессе. Читать файл окружения умеет сам Node: флаг --env-file=.env кладёт переменные из файла в process.env до запуска кода, и с версий 24.10 и 22.21 он не экспериментальный — пакет dotenv для этого больше не нужен. Два свойства флага стоит знать заранее. Переменная, уже заданная в окружении процесса, важнее строки из файла: так платформа выката может переопределить значение, не трогая файл. А если файла нет, Node не запустит код вовсе — напечатает node: .env: not found и завершится с кодом 9.
Файлы примера — .ts, и собирать их не нужно: Node стирает типы сам. У стирания два требования, и оба видны в main.ts. Импорт пишется с расширением, ./config.ts: tsconfig Node не читает и расширения не подбирает, без него файл не найдётся. А тип импортируется с пометкой type — отдельной строкой import type, как здесь, или прямо в списке, import { checkConfig, type Config }: после стирания интерфейса Config в модуле нет, и импорт без пометки упал бы с SyntaxError — модуль не предоставляет экспорт с таким именем.
В .env — тот самый наполовину заполненный пример, в config.ts — функция из прошлого раздела. Прогноз: сколько причин напечатает запуск, с каким кодом завершится процесс и дойдёт ли дело до строки «слушаю»?
# .env.example скопирован на сервер и заполнен наполовину PORT= DATABASE_URL= JWT_SECRET=dev-secret-change-me CORS_ORIGINS=*
конфигурация отвергнута, причин: 4 PORT: нужен номер от 1 до 65535, пришло "" DATABASE_URL: нужен адрес вида postgres://…, пришло "" JWT_SECRET: плейсхолдер из .env.example CORS_ORIGINS: звёздочка вместо списка своих адресов
Четыре причины — к трём знакомым добавился пустой порт, — код выхода 1 и ни строки «слушаю»: до createServer процесс не дошёл. Это и значит «упасть до первого запроса». Порт не открывался, и ни один запрос не мог попасть в сервер с неверной настройкой.
Почему process.exit(1), а не throw? Код выхода был бы тем же: необработанное исключение тоже завершает процесс с 1. Но вместо четырёх строк причин в логе оказался бы стек вызовов, и список поломок пришлось бы выискивать в нём глазами. А сам код выхода — единственное, что видят скрипт выката и оркестратор контейнеров: лог они не читают, они смотрят, жив ли процесс и чем он кончился.
Тот же код с заполненным файлом:
# секрет — openssl rand -hex 32, адрес базы — учётка compose PORT=3940 DATABASE_URL=postgres://app:app@postgres:5432/app_db JWT_SECRET=5c1f0e7a9b3d2c4e8f6a1b0d9e7c3a5f2b8d4e6a0c1f3b5d7e9a2c4f6b8d0e1a CORS_ORIGINS=https://app.example.com, https://admin.example.com
конфигурация принята: порт 3940, CORS для https://app.example.com и https://admin.example.com слушаю 127.0.0.1:3940 GET /health → 200 сервер остановлен
Конфигурация принята, и дальше код получает не process.env, а checked.config: порт уже число, адреса CORS уже массив. Внутри сервера строк из окружения больше нет, а значит, нет и ловушек первого раздела.
В Nest проверку окружения берёт на себя пакет @nestjs/config. В версии 12.0.0 он принимает схему в validationSchema по стандарту Standard Schema — то есть схему zod, valibot или другой библиотеки, которая этот стандарт реализует. Как схема проверяет данные и выводит тип, разбирает «Собираем валидатор границы»; здесь важно, в какой момент она срабатывает.
forRoot возвращает динамический модуль (динамические модули и @Global разобраны отдельно), причём обещанием, Promise<DynamicModule>: Nest дожидается его, пока собирает дерево модулей. Схема проверяется внутри forRoot, поэтому отказ случается раньше, чем создан первый провайдер. С наполовину заполненным окружением NestFactory.create не возвращается: логгер Nest печатает Config validation error:, за ним первую причину в той же строке и остальные — по строке на причину, в виде «путь: сообщение»; процесс завершается с кодом 1, а конструктор провайдера, который читает конфигурацию, не вызывается ни разу. Список причин вместо первой ошибки здесь достаётся даром — его собирает сама схема.
Сравните с тем, как это устроено у автора. Проверка заглушки секрета в main.ts стоит после NestFactory.create, когда провайдеры уже созданы, а провайдер базы берёт адрес через ?? с умолчанием на localhost. Пул соединений из драйвера Postgres pg при создании не соединяется ни с чем — он идёт в базу на первом запросе. Поэтому с пустым или неверным адресом сам процесс стартует, проходит проверку секрета, отвечает 200 на /api/health, который базу не трогает, — и падает на первом обращении к базе: ровно ошибка из первого раздела, только в настоящем проекте.
Две детали схемы стоят комментария. ignoreEnvFile: true — потому что пакет умеет читать .env сам: он по-прежнему зависит от dotenv и по умолчанию ищет файл в текущей папке. Если окружение уже заполнил node --env-file, второй читатель того же файла только путает, откуда пришло значение. И булевы. z.coerce.boolean() приводит значение через Boolean и потому превращает "false" в true — ловушка из первого раздела, спрятанная в библиотеку. Для строк окружения в zod 4 есть z.stringbool(): "false" даёт false, а незнакомое слово — ошибку проверки.
Разобранные значения ConfigService отдаёт уже с типами схемы: config.get("SMTP_SECURE") вернёт false, а не строку. А process.env.SMTP_SECURE останется строкой "false": пакет не перезаписывает переменные, которые уже были в окружении. Ещё один довод читать конфигурацию только через одно место.
В первой части о CORS было сказано честно: со стороны фронтенда он не чинится, только настройками сервера. Теперь сервер ваш, и настройку пишете вы — а для этого нужно точно знать, что она разрешает.
CORS — не защита сервера, а правило браузера. Страница с одного адреса делает запрос к серверу на другом; браузер добавляет заголовок Origin, запрос уходит, сервер его обрабатывает — и только потом браузер решает по заголовкам Access-Control-Allow-*, отдать ли ответ скрипту страницы. Простой запрос — GET или POST, какой отправляет обычная форма, — доходит до сервера и выполняется при любом ответе, в том числе при отказе. Запрос с JSON-телом браузер сначала предваряет пробным OPTIONS и без разрешения настоящий не отправляет. А curl, мобильное приложение и другой сервер CORS не знают вовсе: для них этих заголовков не существует.
Решение браузера описано в стандарте Fetch и умещается в несколько проверок. Куки к запросу на другой адрес браузер прикладывает, только если скрипт попросил credentials: "include", и для такого запроса проверки строже. Ниже эта проверка и сервер, который ставит заголовки по одной из трёх политик: звёздочка, отражение (вернуть в ответе тот Origin, что пришёл) и список. «Своя страница» здесь — фронтенд на другом поддомене, скажем app.example.com при API на api.example.com: странице на одном адресе с API CORS не нужен вовсе. Прогноз: кто прочитает ответ в каждой из четырёх настроек?
Звёздочка без кук пускает всех, и для публичного API это честная настройка: читать там нечего, кроме того, что и так открыто. Звёздочка с куками не пускает никого, и это не причуда модели. Браузер запрещает такое сочетание, потому что «любой сайт с куками пользователя» — ровно то, от чего CORS придуман: настоящий Chrome на fetch с credentials: "include" к серверу со звёздочкой отвечает TypeError, хотя сервер запрос получил.
Дальше случается типичная починка. Своя страница с куками не работает, находится совет «возвращайте в ответе тот Origin, что пришёл», — и всё заработало. Третья строка показывает цену: отражение с куками пускает любую страницу, а браузер доволен, потому что формально каждый ответ адресован ровно тому, кто спросил. Своих от чужих отличает только список.
В Nest на Fastify app.enableCors() отдаёт настройку пакету @fastify/cors — у @nestjs/platform-fastify 12.0.3 это версия 11.3.0, — и его варианты ложатся на модель один в один. Без опций — звёздочка. origin: true — отражение. Массив адресов — список, и сравнение в нём строгое, строка со строкой: https://app.example.com/ с косой чертой в конце не совпадёт ни с одним Origin, потому что в нём косой черты не бывает. А если в массиве окажется "*", весь массив превращается в звёздочку. Поэтому CORS_ORIGINS проверяется на старте вместе с остальным окружением: звёздочку checkConfig уже отвергает, косую черту научит отвергать вторая задача.
Куки добавляют к этой картине своё — SameSite и проверку Origin на запросах, которые меняют данные; это разбирается вместе с проверкой личности в «Проверка личности на каждом запросе».
Мобильному приложению CORS не нужен вовсе — это правило браузера. Если веб-страница живёт на одном адресе с API за прокси, как у автора, в проде CORS можно не включать совсем, и тогда CORS_ORIGINS из проверки уходит.
Последняя настройка старта — где сервер ждёт соединений. Вопросов здесь два, и их часто путают: на каких адресах машины слушает процесс и какие порты машина открывает наружу.
Первый вопрос решает адрес в listen. У машины их несколько: 127.0.0.1 видит только она сама, адрес в сети — соседи, а на сервере весь интернет. Пример запускает сервер на node:http и на Fastify с разными адресами и стучится в каждый двумя путями: через 127.0.0.1 и через адрес машины в сети. Порт везде 0 — свободный выбирает система. Прогноз: какие из четырёх серверов ответят по адресу в сети?
import { createServer } from "node:http"; import { networkInterfaces } from "node:os"; import Fastify from "fastify"; // адрес машины в сети — тот, по которому к ней постучался бы сосед; сам адрес не печатаем const lan = Object.values(networkInterfaces()) .flat() .find((i) => i.family === "IPv4" && !i.internal)?.address; if (!lan) throw new Error("у машины нет адреса в сети — сравнивать не с чем"); async function reach(host, port) { try { const res = await fetch(`http://${host}:${port}/`); await res.text(); return "отвечает"; } catch (error) { return error.cause?.code ?? error.message; } } async function report(label, port) { console.log(`${label}: по 127.0.0.1 — ${await reach("127.0.0.1", port)}, по адресу в сети — ${await reach(lan, port)}`); } for (const host of [undefined, "127.0.0.1"]) { const server = createServer((req, res) => res.end("ok")); // порт 0 — свободный выбирает система, чтобы пример не спорил с чужими серверами await new Promise((done) => (host ? server.listen(0, host, done) : server.listen(0, done))); await report(host ? `node:http, listen(0, "${host}")` : "node:http, listen(0)", server.address().port); server.closeAllConnections(); await new Promise((done) => server.close(done)); } for (const options of [{ port: 0 }, { port: 0, host: "0.0.0.0" }]) { const label = `Fastify, listen(${options.host ? '{ port: 0, host: "0.0.0.0" }' : "{ port: 0 }"})`; const app = Fastify(); app.get("/", async () => "ok"); await app.listen(options); await report(label, app.server.address().port); await app.close(); }
node:http, listen(0): по 127.0.0.1 — отвечает, по адресу в сети — отвечает
node:http, listen(0, "127.0.0.1"): по 127.0.0.1 — отвечает, по адресу в сети — ECONNREFUSED
Fastify, listen({ port: 0 }): по 127.0.0.1 — отвечает, по адресу в сети — ECONNREFUSED
Fastify, listen({ port: 0, host: "0.0.0.0" }): по 127.0.0.1 — отвечает, по адресу в сети — отвечаетУ node:http без адреса — все адреса машины сразу: такой сервер, запущенный на VPS без контейнера, доступен на своём порту всем, до кого дотянется сеть, мимо прокси с HTTPS, — если порт не закрывает файрвол. У Fastify умолчание обратное — только localhost, и снаружи до него не достучаться. Nest на Fastify это умолчание наследует: app.listen(8000) передаёт адаптеру один порт, и Fastify слушает localhost (как Nest отдаёт работу адаптеру — «Express или Fastify под Nest»). Внутри контейнера localhost — сам контейнер, поэтому в main.ts автора стоит app.listen(port, "0.0.0.0") — по той же причине, по какой Next в контейнере слушает все интерфейсы.
В контейнере «все интерфейсы» безопасны: сеть контейнеров закрыта, и снаружи её видно ровно через те порты, которые опубликованы. Это второй вопрос. Вот порты из compose-файла, которым выкладывается API автора:
Строка в ports без адреса публикует порт на всех интерфейсах сервера — так сказано в документации Compose. И здесь ловушка, из-за которой у базы стоит 127.0.0.1. Инструкция выкладки автора включает на сервере ufw и разрешает в нём только SSH, 80 и 443, но порт, опубликованный Docker, идёт мимо этих правил: Docker перенаправляет трафик контейнеров в таблице nat, раньше цепочек, которые проверяет ufw, — это описано в документации Docker. Процесс, запущенный прямо на сервере, ufw закрыл бы, а порт контейнера — нет: строка 5432:5432 открыла бы базу интернету при «закрытом» файрволе.
Отсюда раскладка, при которой вход один. Порты 80 и 443 публикует только прокси. У API строки ports нет вовсе: Caddy ходит в api:8000 по сети compose. База опубликована только на 127.0.0.1 сервера, и разработчик добирается до неё SSH-туннелем: ssh -L 55432:127.0.0.1:55432 пробрасывает порт сервера на ноутбук, никому больше его не открывая. Что ещё делает прокси на входе, разбирает «Обратный прокси, HTTPS и заголовки», а compose целиком — «Compose: база, приложение, тома и порядок старта».
Проверять порты после выката нужно снаружи, с другой машины: на самом сервере отвечает и порт, опубликованный только на 127.0.0.1, поэтому проверка оттуда ничего не доказывает.
- В
process.envтолько строки илиundefined: строкаPORT=даёт"",Number("")— 0,Boolean("false")—true, а??пустую строку не заменяет. Окружение разбирается один раз, на старте, в объект с настоящими типами. - Проверка на старте собирает все причины и завершает процесс кодом 1 до
listen: выкат падает сразу и с полным списком, а не у пользователя через неделю. - Заглушка из
.env.example— секрет, известный всем, кто видел репозиторий: её и короткие секреты отвергают. Послабление для разработки включается только явнымNODE_ENV=development, иначе не доехавшее окружение выключает проверку. node --env-fileзаменяет dotenv, а.tsзапускается без сборки — с расширением в импортах иimport typeдля типов. В Nest схема вConfigModule.forRootотказывает до создания провайдеров; для булевых —z.stringbool(), а неz.coerce.boolean().- CORS — разрешение браузеру отдать ответ чужой странице, а не защита сервера: простой запрос выполняется при любом ответе. Настройка — список своих адресов без косой черты; звёздочка с куками не работает ни для кого, отражение Origin с куками пускает всех.
listenбез адреса вnode:httpслушает все интерфейсы, Fastify по умолчанию — толькоlocalhost, поэтому в контейнере нужен0.0.0.0. Наружу публикуется один прокси, база — на127.0.0.1: порт, опубликованный Docker, обходит ufw.
1/4В .env стоит строка PORT=, а сервер стартует вызовом server.listen(Number(process.env.PORT)). Что произойдёт?
2/4Зачем checkConfig собирает причины в список, а не бросает исключение на первой?
3/4Сервер отвечает заголовками Access-Control-Allow-Origin: * и Access-Control-Allow-Credentials: true. Страница с другого адреса делает fetch методом GET с credentials: "include". Что будет?
4/4В compose у базы ports: ['5432:5432'], на сервере ufw разрешает только SSH, 80 и 443. Кто может подключиться к базе?
Строгий флаг из окружения
Напишите parseFlag: строки "true" и "false" превращаются в значения, всё остальное — в причину отказа с именем переменной. Отсутствующая переменная тоже причина: для флага, который обязателен, молчание не значит «нет».
Сравнение идёт с двумя точными строками, и никакого приведения: Boolean как раз и превращал любую непустую строку в true.
Вывод: { ok: true, value: false }, { ok: true, value: true } и две причины — с "no" и с undefined. Слова вроде yes и 1 можно добавить, но только явным списком: z.stringbool() из zod делает ровно это.
Адреса CORS без косой черты
Браузер присылает Origin без пути и без косой черты в конце, а @fastify/cors сравнивает адреса из списка строго, строка со строкой. Адрес https://app.example.com/ в CORS_ORIGINS поэтому не совпадёт никогда, и своя страница получит отказ, хотя её адрес в списке вроде бы есть. Допишите originProblems: звёздочка и всё, что не имеет вида «схема://хост» или «схема://хост:порт», — причина отказа.
Звёздочка проверяется отдельно, чтобы причина называла её прямо. Остальное — одно регулярное выражение: схема http или https, затем хост с необязательным портом и конец строки, без единой косой черты после.
Первый список проходит без замечаний, у второго одна причина — косая черта, у третьего — звёздочка, а http://localhost:3000 законен: порт входит в адрес источника.
Закрыть лишние порты
Порты API и базы кто-то открыл «для отладки». На сервере включён ufw, в нём разрешены только SSH, 80 и 443. Какие из портов ниже сейчас доступны из интернета? Перепишите ports так, чтобы снаружи отвечал только прокси, а к базе можно было подключиться с ноутбука через SSH-туннель.
Сейчас доступны все четыре порта: строки без адреса публикуют их на всех интерфейсах, а опубликованный Docker порт идёт мимо правил ufw. Разрешений для 5432 и 8000 никто не давал — они и не понадобились.
У API строки ports нет: прокси ходит в api:8000 по сети compose. База видна только самому серверу, а с ноутбука — через ssh -L 55432:127.0.0.1:55432 и подключение к localhost:55432. Номер 55432 вместо 5432 — чтобы туннель не спорил с Postgres, который может работать на самом ноутбуке.