Деплой
Ця сторінка описує, як зібрати застосунок для production, що міститься в Dockerfile і які умови потрібні під час запуску. Змінні оточення детально описано на сторінці Конфігурація, а безпеку — на сторінці Безпека. Тут вони лише згадуються.
Збірка
Кореневий скрипт pnpm build виконує pnpm -r build (package.json). pnpm упорядковує пакети за залежностями workspace, тож shared збирається першим. Dockerfile задає порядок явно: shared → frontend → backend.
- backend:
tsc --build(packages/backend/package.json) компілюєsrc/уdist/(outDir/rootDirуtsconfig.json). Під час виконання аліаси#app/*,#config/*,#vendor/*,#database/*,#drizzle/*і#loggerрезолвляться через полеimportsуpackage.jsonі вказують на./dist/..., томуnode dist/index.js(pnpm start) працює безtsx. - frontend:
buildзапускає type-check,vite buildі окрему збірку service worker (vite.sw.config.ts), див.packages/frontend/package.json. Результат потрапляє вpackages/frontend/dist. - Окремого скрипта copy-static немає. Фронтенд у
packages/backend/publicкопіює самDockerfile(cp -r packages/frontend/dist packages/backend/public).
Як віддається фронтенд
Бекенд віддає статику лише при SERVE_STATIC=true (config/app.ts, default false). Тоді static-server.ts на старті кешує в пам'ять каталог ./public відносно process.cwd() (при APP_ENV=manual-test замість нього береться ./public-test). / віддає index.html. Інший варіант — віддавати frontend/dist окремим хостингом або CDN (CDN_URL, див. Маршрутизація і CDN).
WARNING
Dockerfile копіює фронтенд у public і задає SERVE_STATIC=true та APP_ENV=production, тож контейнер віддає фронтенд без додаткових змінних. Якщо фронтенд віддає проксі або CDN, перевизначте -e SERVE_STATIC=false. Коли SERVE_STATIC=true, а каталогу public немає, сервер пише попередження і стартує без статики (static-server.ts). У .env.example стоїть SERVE_STATIC=false, бо в dev фронтенд віддає Vite.
CSP, коли фронтенд віддає проксі
При SERVE_STATIC=true бекенд сам ставить CSP на HTML (див. Content-Security-Policy). Якщо HTML віддає nginx або CDN, політику документа треба задати там, з тим самим винятком для service worker:
location = /service-worker.js {
# CSP у відповіді зі скриптом worker'а діяла б на сам worker
add_header Cache-Control "no-cache";
}
location / {
try_files $uri /index.html;
add_header Content-Security-Policy "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; style-src-elem 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: blob: https://cdn.example.com; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https://api.example.com wss://api.example.com; upgrade-insecure-requests" always;
}Значення можна взяти з відповіді бекенда: curl -sS -D - -o /dev/null https://<backend>/ при SERVE_STATIC=true.
Локальний production-like запуск
pnpm prod:local (scripts/prod-local.sh) збирає shared, frontend (з VITE_BASE_URL=http://localhost:<PORT>, без Sentry) і backend, копіює frontend/dist у backend/public, як Dockerfile, і стартує бекенд з SERVE_STATIC=true. Потрібні PostgreSQL і Redis з packages/backend/.env. Оскільки це plain http, скрипт задає APP_ENV=local і APP_IS_PRODUCTION=false (інакше getWsUrl() видав би wss://, а upgrade-insecure-requests зламав би всі запити). PROD_LOCAL_SKIP_BUILD=true пропускає збірку.
Dockerfile
Етап builder
- Образ
ubuntu:24.04. Node.js 24 ставиться з NodeSource, pnpm9.15.0вмикається через corepack. - Спочатку копіюються лише
package.json,pnpm-lock.yaml,pnpm-workspace.yamlіpackage.jsonтрьох пакетів, далі виконуєтьсяpnpm install --frozen-lockfile. Так шар із залежностями кешується. COPY . .копіює проєкт без того, що виключає.dockerignore:.git,node_modules,dist,coverage, звіти тестів, кеш VitePress і всі.env/.env.*, крім.env.example. Тому локальні секрети не потрапляють ні в build context, ні в шари builder-етапу, а локальнийpackages/frontend/.envне вшивається у фронтенд-бандл — Vite бачить лишеVITE_SENTRY_*з build-аргументів.- Build-аргументи
VITE_SENTRY_*(DSN, ENABLED, ENVIRONMENT, RELEASE, sample rates) стаютьENV, бо Vite вшиває їх у бандл під час збірки. - Збираються
shared,frontendіbackend, після цьогоfrontend/distкопіюється вbackend/public.
Етап production
- Знову
ubuntu:24.04+ Node 24 + pnpm.COREPACK_HOME=/opt/corepack(доступний на читання всім), щоб користувачappзапускав уже підготовлений pnpm без мережі. Створюється системний користувачapp(uid < 1000) з домашнім каталогом/home/app— його потребує pnpm при one-shot запусках. pnpm install --frozen-lockfile --prod --filter backend...ставить лише production-залежності бекенду та його workspace-залежностей.drizzle-kitміститься вdependenciesбекенду, тому потрапляє в образ.ENV NODE_ENV=production.- З
builderкопіюютьсяpackages/backend/dist,packages/shared/dist,packages/backend/public,packages/backend/public-test,drizzle.config.tsразом зsrc/config/database.ts,src/drizzle/migrationsіdocs/support-knowledge-base(його читаєknowledge-base-init-service.ts). EXPOSE 3000,WORKDIR /app/packages/backend,USER app. Файли застосунку належать root і лише читаються: у рантаймі застосунок нічого не пише на диск.HEALTHCHECK(interval 30 с, timeout 5 с, start-period 20 с, retries 3):curl -fsS http://127.0.0.1:${PORT:-3000}/api/ready | grep -q '"status":"ok"'— readiness: контейнерhealthy, лише коли відповідають PostgreSQL і Redis (див. Observability). Перевіряється тіло, а не лише код: ізSERVE_STATIC=trueбудь-який невідомий шлях повертаєindex.htmlзі статусом 200 (SPA fallback), тож перевірка лише статусу проходила б і при неправильному шляху.CMD ["node", "dist/index.js"]— PID 1 є Node, тожSIGTERMвідdocker stopдоходить доstopGracefully. Міграції на старті не запускаються.
Чого в образі немає:
.envу production-етап не копіюється. Конфігурація передається через-e/--env-file.dotenv/configуindex.tsпросто не знайде файл.- Вихідних кодів
src/(окрім міграцій), devDependencies іtsxтеж немає.
Команди
docker build -t vibe-template .
# з Sentry для фронтенду:
docker build -t vibe-template --build-arg VITE_SENTRY_DSN=<dsn> --build-arg VITE_SENTRY_ENABLED=true .
# один раз перед rollout нової версії (той самий образ, без -p):
docker run --rm --env-file ./production.env vibe-template pnpm db:create:prod
docker run --rm --env-file ./production.env vibe-template pnpm db:migrate
docker run -d -p 3000:3000 --env-file ./production.env vibe-templateЗміна запуску (feat-019)
До feat-019 контейнер сам виконував db:create:prod і db:migrate на кожному старті. Тепер це окремі one-shot запуски: якщо їх пропустити, нова версія стартує на старій схемі. У Kubernetes це init-контейнер або Job перед rollout, у Compose — окремий сервіс з restart: "no", від якого залежить застосунок (depends_on: condition: service_completed_successfully).
Healthcheck з іншим PORT або API_PATH_PREFIX. Вбудована перевірка знає лише PORT і префікс api. Для іншого префікса перевизначте її при запуску, інакше контейнер стане unhealthy:
docker run -d -e API_PATH_PREFIX=v1 \
--health-cmd "curl -fsS http://127.0.0.1:3000/v1/ready | grep -q '\"status\":\"ok\"' || exit 1" \
--env-file ./production.env vibe-templateHealthcheck б'є в /api/ready (readiness): при недоступній БД чи Redis контейнер стає unhealthy. Docker сам контейнер не перезапускає; оркестратор, що зважає на health, виведе його з балансування. Liveness без залежностей — /api/ping.
production.env — ваш файл зі змінними за сторінкою Конфігурація (секрети в ньому лише ваші, у репозиторії їх немає). Хост і порт задаються через HOST (default 0.0.0.0) і PORT (default 3000).
Перевірка (2026-10-24, feat-019)
macOS, Docker Desktop, docker build --no-cache:
| До | Після | |
|---|---|---|
| Build context | 83,45 MB | 124,7 kB |
| Час збірки | 337 с | 172 с |
| Розмір образу | 813 MB | 813 MB |
- Секрети: canary-маркер у кореневому
.envіpackages/shared/.envне знайдено ні в builder-етапі (grep -rпо всій ФС), ні в жодному шарі фінального образу (docker save+grep), ні вdocker history; файлів.envв образах немає. Позитивний контроль тих самих команд знаходить.env.exampleу builder-етапі іstopGracefullyу шарах. - Одноразові
pnpm db:create:prodіpnpm db:migrateвід користувачаappна чистому PostgreSQL з pgvector — успішно; на старті застосунку міграцій у лозі немає. id -uу контейнері —999(app); PID 1 — Node;healthyза замовчуванням; зAPI_PATH_PREFIX=v1контейнер працює, але стаєunhealthy, а з перевизначеним--health-cmd—healthy.docker stop -t 10— у лозіstop SIGTERMіserver stop type: SIGTERM, зупинка за 1 с, код виходу 0.
Runtime
- Змінні: повний перелік наведено на сторінці Конфігурація, про production-like режим (
APP_IS_PRODUCTIONабоAPP_ENV/NODE_ENVзі значеннямproduction/prod) див. Production-like режим. Образ задаєNODE_ENV=productionіAPP_ENV=production:secureу cookie і конфіг CSP (config/csp.ts, зокремаwss:уgetWsUrl()) читають самеAPP_ENV. Для staging-оточення перевизначтеAPP_ENVпри запуску. - TLS і проксі: сервер слухає HTTP (
server.listen(host, port)уserver.ts), тож TLS завершується на реверс-проксі. За проксі потрібніTRUST_PROXY=trueіTRUSTED_PROXY_CIDRS, див. Trust proxy. - Unix socket: якщо
APP_UNIX_PATHзадано й не порожнє, сервер викликаєlisten_unixзамість TCP, іHOST/PORTтоді ігноруються. ПорожнєAPP_UNIX_PATH=(як у.env.example) означає TCP наHOST:PORT. - Graceful shutdown:
index.tsобробляєSIGINT,SIGHUP,SIGTERMіuncaughtException.stopGracefullyзакриває WebSocket-з'єднання та listen socket, робитьflushSentry (2 с), закриває пул PostgreSQL і Redis, а потім викликаєprocess.exit(0). У контейнері PID 1 — Node (CMDу exec-формі), тожdocker stopзакінчується черезstopGracefullyз кодом 0. - Sentry: бекенд читає
SENTRY_DSN,SENTRY_ENVIRONMENT,SENTRY_RELEASEтощо під час виконання (config/sentry.ts). Фронтенду потрібніVITE_SENTRY_*як--build-argпід час збірки.
Rate limit на edge
Локальні ліміти бекенду (L1, маршрути, upgrade рівня 1, WS-повідомлення) живуть у пам'яті кожного процесу і захищають сам процес; з N інстансами їхній бюджет N×. Чутливі дії (вхід, реєстрація, SMS, скидання пароля) і upgrade рівня 2 обмежує спільний бюджет у Redis, див. Обмеження частоти запитів. Ліміт на реверс-проксі — опційний додатковий шар на вході, а не заміна відсутньої функції. Приклад для nginx із шляхами за замовчуванням (API_PATH_PREFIX=api):
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=ws_upgrade:10m rate=1r/s;
limit_conn_zone $binary_remote_addr zone=ws_conn:10m;
# За замовчуванням nginx відповідає 503; клієнту потрібен 429.
limit_req_status 429;
limit_conn_status 429;
location /api/websocket {
limit_req zone=ws_upgrade burst=20 nodelay;
limit_conn ws_conn 20;
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# Any proxy_set_header here cancels inherited ones: repeat the forwarding headers.
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Real-IP $remote_addr;
}
location /api/ {
limit_req zone=api burst=50 nodelay;
proxy_pass http://backend;
# uWebSockets.js answers 505 to HTTP/1.0, nginx's default for upstreams.
proxy_http_version 1.1;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Real-IP $remote_addr;
}Зона limit_req_zone спільна лише в межах одного nginx. Якщо незалежних edge-вузлів кілька, кожен рахує свою квоту. Деталі в документації nginx. Щоб бекенд бачив справжній IP клієнта, потрібні TRUST_PROXY=true і TRUSTED_PROXY_CIDRS.
Перезаписуйте forwarding-заголовки
Без proxy_set_header nginx пересилає X-Forwarded-For клієнта як є. Бекенд довіряє nginx і бере з ланцюжка найправіший недовірений IP, тобто значення, яке клієнт вписав сам. Ротацією підробленого X-Forwarded-For можна обійти всі per-IP ліміти бекенду, зокрема ліміт логіну. Edge-ліміт nginx при цьому лишається чинним.
- Якщо nginx — перший вузол, що приймає клієнта, найпростіше задати
X-Forwarded-For $remote_addrіX-Real-IP $remote_addr, як у прикладі.$proxy_add_x_forwarded_forтеж безпечний: він дописує справжній IP праворуч, аgetIP()обходить ланцюжок справа наліво і пропускає лише довірені проксі, тож підроблений префікс клієнта ігнорується. Але для цього кожен довірений проксі має коректно дописувати ланцюжок, аTRUSTED_PROXY_CIDRSмає точно відповідати адресам цих проксі. proxy_set_headerуспадковується з батьківського рівня лише тоді, коли вlocationнемає жодного власногоproxy_set_header(документація nginx). Тому в WS-блоці, де заданоUpgradeіConnection, forwarding-заголовки треба повторити.- Якщо перед nginx стоїть ще один довірений балансувальник, використовуйте
$proxy_add_x_forwarded_forі додайте адресу балансувальника вTRUSTED_PROXY_CIDRS. Абоset_real_ip_from+real_ip_headerу nginx.
Перевірка: з низьким лімітом бекенду надішліть кілька запитів, кожен з іншим підробленим X-Forwarded-For. Квота має вичерпатися так само, як без заголовка:
for i in 1 2 3 4 5 6; do
curl -s -o /dev/null -w '%{http_code}\n' -H "X-Forwarded-For: 203.0.113.$i" https://<host>/api/main/init
doneБаза даних у production
pnpm db:create:prod(node dist/db/create-database.js) створює БД і виконуєCREATE EXTENSION IF NOT EXISTS vector, тому PostgreSQL має містити pgvector. Що це за вимога і як її виконати на керованому PostgreSQL, див. База даних → pgvector.pnpm db:migrate(drizzle-kit migrate) працює в образі, бо там єdrizzle-kit,drizzle.config.ts,src/config/database.tsіsrc/drizzle/migrations. Застосунок,db:create:prodіdb:migrateберутьPGSQL_*з однаковими defaults; у продакшні все одно задавайте їх явно.- Міграції — окремий one-shot запуск образу перед rollout (див. Команди); репліки застосунку їх не запускають, тож гонки між репліками немає.
Redis і S3
- Redis обов'язковий: у ньому зберігаються сесії, і під час shutdown застосунок закриває з'єднання з ним. Див. Redis і Конфігурація → Redis.
corsS3.xmlу корені — CORS-конфігурація бакета S3/MinIO: лишеGET/HEADз origin застосунку (placeholderhttps://app.example.com). Файли вантажить і видаляє бекенд, тож браузеру запис у бакет не потрібен. Застосовується вручну до бакета, код її не читає. Для кількох оточень див.corsS3.prod.example.xml.
CI/CD
У .github/workflows є лише docs.yml, який збирає і деплоїть цей сайт документації.
WARNING
Workflow для збірки, тестів або деплою самого застосунку немає. Docker-образ збирається і публікується вручну.
Чекліст
- [ ] PostgreSQL з pgvector доступний, усі
PGSQL_*задані явно. - [ ] Redis доступний.
- [ ]
pnpm db:create:prodіpnpm db:migrateвиконано one-shot запуском образу перед rollout. - [ ] Healthcheck відповідає вашим
PORT/API_PATH_PREFIX. - [ ]
CSRF_ALLOWED_ORIGINSз origin фронтенду: образ задаєAPP_ENV=production, і без цієї змінної сервер не стартує (CSRF). - [ ]
APP_ENV=production,APP_KEYі решта пунктів із чекліста безпеки. - [ ] TLS на проксі,
TRUST_PROXYіTRUSTED_PROXY_CIDRSналаштовано. - [ ] Визначено, хто віддає фронтенд:
SERVE_STATIC=trueабо окремий хостинг/CDN. - [ ]
VITE_SENTRY_*передано як build-args,SENTRY_*— як runtime env. - [ ] CORS бакета (
corsS3.xml) обмежено вашим доменом.