RE:NODE

Приложения12 мин чтения

Деплой Node.js-приложения из GitHub и повторный деплой при push

От пустого сервера до работающего Node-приложения: команда запуска, порт, переменные окружения, домен перед ним и автоматический повторный деплой при push.

Обновлено

3 прочтений

Смысл хостинга приложений в том, что вам никогда не придётся загружать zip-файл. Node-приложение разворачивается из репозитория GitHub тремя настройками: какой репозиторий и ветка, какая команда его запускает и нужно ли перезапускать деплой при push в эту ветку. Всё остальное в этой статье - подробности вокруг этих трёх: каким должен быть репозиторий, чтобы всё заработало, какой порт слушать, куда класть секреты и восемь вещей, которые ломаются при чьём-то первом деплое.

Статья написана для хостинга на контейнерах с панелью, что сегодня и называют хостингом приложений. Та же схема подходит для обычного VDS с юнитом systemd и вебхуком, и это совсем другая работа, чем конвейер сборки, выпускающий образ контейнера: здесь образа нет, только ваш исходный код и команда.

Что на самом деле делает деплой из Git#

Деплой из Git - это clone или pull в собственную файловую систему сервера с последующим выполнением команды запуска. Вот и весь механизм, и понимание этого убирает большую часть путаницы.

Что из этого следует:

  • Сервера сборки нет. Если проекту нужен этап сборки, он выполняется в том же контейнере, что обслуживает трафик, на той же памяти и той же доле CPU. Сборка TypeScript или Next.js на тарифе в 1 GB - самая частая причина гибели первого деплоя.
  • Файлы на сервере - это рабочая копия. Всё, что приложение записывает во время работы в каталог репозитория, - загрузки, файл SQLite, сгенерированная конфигурация, - под угрозой при следующем pull. Храните данные времени выполнения вне рабочей копии или хотя бы вне того, что отслеживает Git.
  • Ваша ветка - это и есть развёрнутое состояние. Отдельного «релиза» нет. Откат означает push с revert или переключение сервера на другую ветку и перезапуск.
  • Для приватного репозитория нужен авторизованный pull. Для этого и существует GitHub App, и поэтому вставлять персональный токен доступа в конфигурационный файл - неправильный ответ.

Если вам нужно отдельное место, чтобы проверить эту схему до того, как она дойдёт до игроков или клиентов, справится второй недорогой сервер в том же аккаунте, привязанный к ветке staging. Как не дать им разойтись, описано в статье staging и production в одном аккаунте.

Подготовка репозитория#

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

Закоммитьте lockfile. package-lock.json должен лежать в репозитории. Без него нет воспроизводимой установки, а npm ci вообще не запустится.

Игнорируйте `node_modules`. Она пересобирается на сервере, она огромна, а закоммитенная копия, собранная на Windows или macOS, содержит нативные бинарные файлы, которые не запустятся на Linux.

Заведите настоящий скрипт start и убедитесь, что он запускает собранный результат, а не сервер разработки. next dev, nodemon и ts-node - не производственные команды: они следят за файловой системой, расходуют больше памяти, а некоторые не переживают перезапуск чисто.

Укажите версию Node. Поле engines - документация и для вас, и для установщика, и именно его нужно проверить первым, когда что-то работает локально, но не на сервере.

Не пускайте `.env` в репозиторий. Добавьте .env в .gitignore и закоммитьте .env.example с ключами и без значений, чтобы следующий человек знал, что заполнять.

package.json
{  "name": "example-api",  "private": true,  "type": "module",  "engines": { "node": ">=20" },  "scripts": {    "build": "tsc -p tsconfig.json",    "start": "node dist/server.js"  },  "dependencies": { "express": "^4.21.2" },  "devDependencies": { "typescript": "^5.7.2" }}

Подключение GitHub к серверу#

На вкладке GitHub сервера установите приложение в свой аккаунт или организацию, дайте ему доступ к нужным репозиториям, затем выберите один репозиторий и одну ветку.

Панель общается с GitHub через GitHub App с недолговечными токенами установки, а не просит вставить персональный токен доступа. Это важно по двум причинам. Приватные репозитории работают без учётных данных, хранящихся в конфигурации вашего сервера, а токен, который нужно не забыть отозвать, - это токен, который так и не отзывают. Отзыв доступа приложения к репозиторию в GitHub действует сразу, и на стороне сервера убирать ничего не нужно.

GitHub здесь - единственный провайдер. Если ваш код лежит на GitLab, Gitea или на частном сервере, зеркалируйте ветку на GitHub или загружайте по SFTP; как сделать это аккуратно, описано в статье SFTP и файловый менеджер.

Команда запуска#

Команда запуска выполняется при каждом старте контейнера, а не только при деплое. Для скомпилированного проекта с закоммиченным результатом сборки это всё, что нужно:

bash
npm ci --omit=dev && node dist/server.js

Используйте npm ci, а не npm install. npm ci устанавливает ровно то, что написано в lockfile, и громко падает, если lockfile и package.json расходятся, а это именно то, что нужно на сервере. npm install тихо разрешает не то, что вы тестировали, а затем записывает изменённый lockfile, которого нет в вашем репозитории. Флаг --omit=dev пропускает devDependencies, что обычно вдвое сокращает и время установки, и занятое место.

Если сборка должна выполняться на сервере, схема такая:

bash
npm ci && npm run build && npm prune --omit=dev && node dist/server.js

Она устанавливает всё, включая компилятор, собирает, снова удаляет пакеты для разработки и запускает. Это правильно и медленно: ждите от 30 до 90 секунд, пока процесс начнёт слушать, при каждом перезапуске, и пик памяти при сборке нередко в два-три раза выше того, что нужно работающему приложению. На тарифе в 1 GB сборка TypeScript или бандлера - самое вероятное, что упрётся в потолок. Два выхода в порядке предпочтения: собирать в GitHub Actions и коммитить или публиковать результат, либо на время сборок перейти на тариф выше по памяти и вернуться, если поймёте, что он не нужен. Флаги кучи и то, что на самом деле делает лимит контейнера, разбирает статья лимиты памяти Node.

Порт и окружение вокруг него#

К вашему тарифу прилагается выделение - адрес и порт, показанные на вкладке Network, - и именно этот порт должно слушать ваше приложение. Два правила, на которых спотыкаются:

Читайте порт из окружения, никогда не прописывайте его жёстко. Панели передают выделенный порт процессу как переменную окружения, а приложения на Node по соглашению читают PORT. Принимайте обе и предусмотрите разумное значение для локальной разработки:

src/server.js
import express from "express";const app = express();const port = Number(process.env.PORT ?? process.env.SERVER_PORT ?? 3000);app.set("trust proxy", 1);app.get("/healthz", (req, res) => res.status(200).send("ok"));const server = app.listen(port, "0.0.0.0", () => {  console.log(`listening on ${port}`);});process.on("SIGTERM", () => {  server.close(() => process.exit(0));});

Привязывайтесь к `0.0.0.0`, а не к `127.0.0.1`. Это причина номер один для «оно работает, в логе написано listening, а достучаться нельзя». Внутри контейнера localhost означает сам контейнер и ничего больше. Express по умолчанию слушает все интерфейсы, если опустить хост, но многие фреймворки и примеры по умолчанию берут loopback, и у превью-сервера Vite и у Next.js для этого есть флаги.

Переменные окружения живут на вкладке Startup. Они задаются на контейнере, так что ваш код читает их обычным образом, и ни один секрет не попадает в то, что вы отправляете в репозиторий. Здесь должны лежать учётные данные базы данных, ключи API, адреса вебхуков и токены ботов - полный список и что делать, если один уже закоммичен, - в статье переменные окружения и секреты.

Падайте сразу, если чего-то не хватает, а не запускайтесь и не обнаруживайте это при первом запросе:

javascript
const required = ["DATABASE_URL", "SESSION_SECRET"];const missing = required.filter((name) => !process.env[name]);if (missing.length) {  console.error(`missing environment variables: ${missing.join(", ")}`);  process.exit(1);}

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

Автообновление и деплой при push#

Есть два независимых переключателя, и делают они разное.

Автообновление выполняет pull ветки при каждом запуске контейнера. Перезапуск по любой причине - вы нажали кнопку, сработало расписание, процесс упал - приносит с собой последний коммит. Это удобно и стоит знать, потому что сломанный коммит в вашей ветке может подхватиться перезапуском, который вы не считали деплоем.

Деплой при push реагирует на сообщение GitHub о push в эту ветку: он делает pull и перезапускает сервер. Он перезапускает только уже работавший сервер, так что намеренно остановленный сервер остаётся остановленным. Эта деталь избавляет от многих недоразумений, когда вы работаете над тем, что пока не должно быть в эфире.

push в mainсобытие pushpull и перезапускзакрыта, когда работаетВыgit pushGitHub Appнедолговечный токенЗапись о деплоеоткрывается при pushВаш контейнерpull, установка, запуск
Что происходит между push и работающим процессом

Панель хранит по одной записи на каждый деплой: она открывается, когда приходит push, и закрывается, когда контейнер снова замечен работающим. Это и есть разница между деплоем, который состоялся, и деплоем, который упал, и первое место, куда смотреть, когда сайт всё ещё отдаёт вчерашний код: либо записи нет - значит, неверны вебхук или название ветки, - либо запись открыта и не закрывается - значит, контейнер не поднимается, и причина в консоли.

Разумный вариант по умолчанию для небольшого проекта: деплой при push включён на рабочей ветке и автообновление тоже включено, чтобы перезапуск после сбоя не откатывал вас на устаревшую рабочую копию. Если вы пушите в main много раз в день, отключите деплой при push и используйте ветку release, в которую вы вливаете изменения осознанно.

Домен перед приложением#

Тарифы для приложений и сайтов включают слот обратного прокси. Направьте имя хоста на адрес, показанный на слоте, записью A, и сертификат выпускается и продлевается автоматически - продление происходит в окне 21 день, так что ничего ежегодного помнить не нужно.

Три вещи, которые нужно сделать в самом приложении, когда оно стоит за прокси:

  1. Доверяйте прокси. Настоящий IP клиента приходит в X-Forwarded-For. Пока вы не скажете фреймворку доверять этому заголовку, каждый запрос кажется приходящим от прокси, что ломает ограничение частоты, геолокацию и ваши логи. В Express это app.set("trust proxy", 1).
  2. Не принуждайте к HTTPS внутри приложения. Прокси завершает TLS и говорит с вашим контейнером по обычному HTTP. Приложение, которое перенаправляет любой не-HTTPS запрос на HTTPS, зациклится навсегда. Если нужно знать схему, проверяйте X-Forwarded-Proto.
  3. Собирайте абсолютные URL из публичного имени хоста, а не из заголовка host запроса, иначе колбэки OAuth и ссылки в письмах будут вести на внутренний адрес.

Полное описание вместе с записями DNS и причинами, по которым выпуск не проходит, - в статье как направить домен на сервер и получить сертификат SSL, а что делает обратный прокси - это общий фон, если понятие для вас новое. Если приложение использует WebSocket, прочтите WebSocket за обратным прокси, прежде чем разбираться с обрывом соединения каждые шестьдесят секунд.

Если первый деплой не удался#

Примерно в порядке частоты:

При push ничего не происходит. Проверьте, что название ветки совпадает точно, включая регистр. Проверьте, что у GitHub App всё ещё есть доступ к этому репозиторию. Проверьте, что сервер был запущен: деплой при push не запускает остановленный сервер.

`npm ci` завершается с `EUSAGE`. Lockfile и package.json расходятся, либо lockfile отсутствует. Выполните npm install локально, закоммитьте обновлённый lockfile и сделайте push ещё раз.

Сборка убита без ошибки. Это лимит памяти. Контейнер останавливают на потолке и запускают заново, а не позволяют уйти в swap, поэтому вы получаете обрезанный лог, а не стек вызовов. Собирайте в другом месте или перейдите на тариф выше.

В логе listening, но подключиться нельзя. Привязка к 127.0.0.1 либо к порту, отличному от выделенного. Иногда и то и другое.

`Error: Cannot find module` для того, что есть в package.json. Почти всегда --omit=dev убирает пакет, который вашему рантайму на деле нужен: псевдонимы путей TypeScript и некоторые ORM подтягивают инструменты во время выполнения. Переместите пакет в dependencies.

Нативный модуль не загружается. bcrypt, sharp, better-sqlite3 и подобные компилируются под платформу. Никогда не коммитьте node_modules; пусть npm ci соберёт их на сервере. Если в сборке нет компилятора, переходите на чистую JS-альтернативу (bcryptjs) или на готовый пакет.

Процесс перезапускается каждые несколько секунд. Читайте консоль с начала, а не с конца: полезная ошибка - первая, а цикл её хоронит. Именно об этом статья как читать консоль, а почему игровой сервер постоянно перезапускается рассказывает о защите от циклов сбоев, которая в итоге вмешивается.

`EADDRINUSE`. Предыдущий процесс не завершился. Это отсутствие обработчика SIGTERM; см. блок с server.close() выше и статью корректное завершение и проверки работоспособности.

FAQ#

Нужен ли Docker, чтобы разворачивать Node-приложение таким способом?

Нет. Сервер уже запускает ваш процесс в контейнере; вы предоставляете исходный код и команду запуска, а не образ. Собственные образы в эту схему не входят. Если вам нужно именно управлять образом рантайма, это задача для VDS - см. как выбрать между VDS и игровой панелью.

Можно ли разворачивать из GitLab или с частного Git-сервера?

Напрямую нет: интеграция здесь только с GitHub, через GitHub App. Обычный обходной путь - зеркало: пушить в оба удалённых репозитория либо добавить задание GitLab CI, которое пушит в репозиторий GitHub, за которым следит сервер. Иначе загрузите собранное приложение по SFTP.

Как откатить неудачный деплой?

Сделайте push с revert. Выполните git revert для коммита и push, и деплой при push развернёт его так же, как развернул ошибку. Если приложение лежит и его нужно поднять немедленно, быстрее переключить сервер на предыдущую ветку или тег и перезапустить, но не забудьте вернуть его обратно, иначе следующий push будет выглядеть так, будто ничего не делает.

Выполняется ли npm ci при каждом перезапуске?

Да, если он входит в вашу команду запуска, и обычно это то, что нужно: так гарантируется, что установленное совпадает с lockfile. Цена - от 20 до 60 секунд времени старта на небольшом тарифе. Если ваши зависимости стабильны, а время запуска важнее, можно убрать npm ci из команды запуска и выполнять его вручную после смены зависимостей, но тогда pull, меняющий зависимости, запустится с неправильно установленными.

Что происходит с загрузками и файлами, которые пишет приложение?

Они лежат на диске сервера и переживают перезапуски, но всё внутри рабочей копии Git может быть перезаписано pull. Записывайте пользовательские загрузки в каталог вне пути репозитория либо в каталог из .gitignore и включайте его в backup - слоты backup идут вместе с тарифом и могут выполняться по расписанию.

Сколько памяти нужно Node-приложению?

Небольшому API или боту для Discord в покое комфортно в 512 MB - 1 GB; запас нужен на этапе сборки. Начните с 1 GB, неделю понаблюдайте за графиком в консоли и повышайте, если потолок достигается во время сборок, а не во время трафика. Цифры разбирают статьи как выбрать тариф для бота Discord и как подобрать размер веб-приложения к дню запуска.


Комментарии

Полностью анонимно: без аккаунта, без почты, без cookie. Мы храним имя, которое вы ввели, текст и время - больше ничего. Количество ссылок ограничено, разметка не отображается.

0/2000