Ідеальний деплой Next.js за допомогою Docker: Чому час забути про PM2
Якщо ти створюєш сучасні веб-додатки на Next.js, рано чи пізно постає питання деплою на власний VPS-сервер. І тут більшість розробників йдуть найпростішим шляхом: завантажують код на сервер, запускають pnpm install, роблять pnpm build і вішають процес на PM2.
Звучить знайомо? Це працює. Але рівно до того моменту, поки твій проєкт не починає масштабуватися, або поки ти не вирішиш оновити версію Node.js на сервері й випадково не покладеш усі сусідні проєкти.
Сьогодні ми поговоримо про те, як розгорнути Next.js по-дорослому — використовуючи Docker.
Що таке Docker людською мовою?
Уяви, що ти переїжджаєш у новий офіс. Ти можеш перевозити речі в пакетах, розпихати їх по кишенях і складати на заднє сидіння авто (це традиційний деплой). А можеш запакувати все у стандартизовані залізні морські контейнери. Вантажникам (серверу) абсолютно байдуже, що всередині контейнера — меблі, сервери чи банани. Вони просто беруть контейнер і ставлять його на місце.
Docker — це інструмент, який дозволяє запакувати твій додаток (код, середовище виконання Node.js, пакети та всі залежності) в один такий ізольований "контейнер" (образ).
Контейнер містить абсолютно все необхідне для запуску проєкту. Тобі більше не потрібно встановлювати Node.js чи pnpm на самому VPS-сервері — вони вже є всередині контейнера.
PM2 vs Docker: Чому PM2 більше не вистачає?
PM2 — це чудовий менеджер процесів. Але він працює поверх твоєї операційної системи. Якщо ти використовуєш PM2, ти стикаєшся з низкою системних проблем:
Конфлікт версій: Одному проєкту потрібен Node.js 18, іншому (наприклад, на Next.js 16) — Node.js 20. Починаються танці з NVM, які часто призводять до плутанини.
Засмічення сервера: Ти встановлюєш глобальні пакети, генеруєш кеш, і з часом твій чистий Ubuntu-сервер перетворюється на звалище забутих залежностей.
Синдром "У мене на комп'ютері працювало": Локально в тебе macOS, а на сервері Linux. Локально одна версія бібліотек, на сервері — інша. Проєкт ламається під час білду на сервері.
Docker вирішує це миттєво: Образ контейнера збирається один раз. Якщо контейнер успішно запустився на твоєму локальному комп'ютері (неважливо, Mac чи Windows), він із стовідсотковою гарантією точно так само запуститься на будь-якому VPS під управлінням Linux.
Безпека та ізоляція: Залізний щит для проєкту
Безпека — це те, де Docker розкривається на повну:
Ізоляція файлової системи: Додаток у Docker працює у власному замкненому середовищі. Навіть якщо хакер знайде вразливість у твоєму Next.js коді і зможе виконати команду на сервері, він опиниться всередині контейнера. Він не матиме доступу до кореневої системи твого VPS, до баз даних інших проєктів чи SSL-сертифікатів Nginx.
Обмеження ресурсів: PM2 може дозволити одному процесу "з'їсти" всю оперативну пам'ять сервера (memory leak), поклавши взагалі всі сайти на машині. У Docker ти можеш жорстко сказати: "Цей контейнер має право використовувати максимум 1 ГБ RAM і 50% процесора".
Ізоляція мережі: Контейнери можуть спілкуватися між собою через закриті віртуальні мережі, які взагалі не "дивляться" назовні. Базу даних (наприклад, PostgreSQL) можна сховати так, що доступ до неї матиме лише конкретний контейнер з твоїм додатком.
Зручність та швидкість деплою
Швидкий відкат (Rollback): Ти залив нову фічу, і на продакшені все впало. З PM2 тобі треба робити
git revert, знову чекатиpnpm installтаpnpm build, втрачаючи дорогоцінні хвилини простою. З Docker ти просто зупиняєш поточний контейнер і запускаєш попередній образ. Відкат займає 2 секунди.Зручність переїзду: Сервер "згорів" або став занадто слабким? Щоб перенести проєкти без Docker, тобі треба заново налаштовувати оточення на новому сервері. З Docker ти просто вводиш одну команду
docker run...на новій машині — і все працює.
Підсумок
PM2 — це інструмент з минулого, який підходить для дуже простих скриптів. Docker — це сучасний стандарт індустрії. Він дає розробнику спокій, прогнозованість і абсолютний контроль над тим, як і де працює його код.
Переходимо до практики. Ти вже розумієш, чому ми це робимо, тепер настав час "залити бетон" — підготувати наш VPS на базі Ubuntu для роботи з Docker.
Ми не просто поставимо Docker, ми налаштуємо його так, щоб він працював як годинник, був безпечним і готовим до деплою твоїх проектів на Next.js.
Крок 1: Оновлення системи та підготовка середовища
Перед встановленням будь-якого софту на VPS, завжди оновлюй індекси пакетів. Це золоте правило безпеки та стабільності.
Bash
sudo apt update && sudo apt upgrade -y
Встановимо необхідні залежності, які дозволять apt працювати з репозиторіями через HTTPS:
Bash
sudo apt install apt-transport-https ca-certificates curl gnupg lsb-release -y
Крок 2: Додавання офіційного репозиторію Docker
Ми не будемо ставити Docker із системних репозиторіїв Ubuntu (там часто лежать застарілі версії). Ми додамо офіційний репозиторій Docker, щоб мати актуальний Docker Engine та Docker Compose.
Додаємо офіційний GPG-ключ Docker:
Bash
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpg
Додаємо репозиторій у список джерел:
Bash
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \
$(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
Крок 3: Встановлення Docker Engine
Тепер оновлюємо список пакетів і встановлюємо Docker разом із docker-compose (який зараз йде як плагін docker-compose-plugin):
Bash
sudo apt update
sudo apt install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin -y
Перевіримо, чи все сталося правильно:
Bash
sudo docker --version
sudo docker compose version
Крок 4: Налаштування Docker без прав root (безпека)
За замовчуванням Docker потребує sudo для кожної команди. Це незручно і небезпечно для автоматизації. Додамо твого користувача в групу docker:
Bash
sudo usermod -aG docker $USER
Важливо: Після цього команди usermod потрібно перезайти в сесію SSH (або просто виконати newgrp docker), щоб зміни вступили в силу. Тепер ти зможеш запускати контейнери без sudo.
Крок 5: Оптимізація та безпека (Production Ready)
Щоб Docker не займав зайвого місця і працював прогнозовано, давай створимо конфігураційний файл, який обмежить розмір логів (це часто забувають, і логи забивають весь диск):
Створи файл
/etc/docker/daemon.json:
Bash
sudo nano /etc/docker/daemon.json
Встав туди ці налаштування:
JSON
{
"log-driver": "json-file",
"log-opts": {
"max-size": "10m",
"max-file": "3"
},
"default-ulimits": {
"nofile": {
"Name": "nofile",
"Hard": 65536,
"Soft": 65536
}
}
}
Це обмежить кожен лог-файл до 10 МБ і залишить тільки 3 останні копії.
Перезапусти Docker:
Bash
sudo systemctl restart docker
Крок 6: Docker Compose — наш найкращий друг
Оскільки я користуюсь панеллю Hestia на Ubuntu, важливо, щоб Docker не конфліктував із налаштуваннями мережі Nginx. Ми будемо використовувати docker compose, щоб описувати всю інфраструктуру (Next.js + PostgreSQL + Redis) в одному файлі.
Перевіримо, чи стартує Docker автоматично після перезавантаження сервера:
Bash
sudo systemctl enable docker
Що ми маємо зараз:
Найактуальніша версія Docker та Docker Compose.
Конфігурацію, яка не "з'їсть" весь диск логами.
Безпечний доступ до Docker без постійного введення
sudo.
Архітектура нашого рішення — Dockerfile, Compose та Telegram-бот
Тепер переходимо до самого серця нашої системи. Щоб зробити деплой максимально швидким і безпечним, ми використовуємо підхід Multi-stage build (багатоетапна збірка).
Dockerfile: Чому це "золотий стандарт"
Твій Dockerfile — це три окремі "сцени" в одному файлі. Чому це круто?
deps: Ми встановлюємо всі залежності (node_modules).
builder: Ми збираємо (build) проєкт, маючи всі залежності.
runner: Ми створюємо мініатюрний образ, куди копіюємо лише результат збірки (
standaloneпапку) і мінімум необхідного для запуску. Це робить фінальний Docker-образ у 5-10 разів легшим за звичайний.
node:22-slim: Ми не беремоalpine, боslim(на базі Debian) стабільніший у роботі з native-бібліотеками (якopenssl, який потрібен для Prisma).Standalone mode: У
pnpm buildми використовуємо Standalone-режим Next.js. Це створює папку, яка містить лише той код, що потрібен для запуску сервера, без зайвого "сміття".Security (User nextjs): Ми створюємо користувача
nextjsз правами1001. Якщо раптом контейнер буде зламано, зловмисник не матиме правrootна сервері. Це захист від виходу за межі контейнера.
Dockerfile
# syntax=docker/dockerfile:1
# 1. BASE - Change alpine to slim (Debian)
FROM node:22-slim AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
# Install OpenSSL (needed for Prisma) and ca-certificates
RUN apt-get update -y && apt-get install -y openssl ca-certificates wget
RUN corepack enable
WORKDIR /app
#2. DEPS
FROM base AS deps
COPY package.json pnpm-lock.yaml ./
COPY prisma ./prisma/
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm install --frozen-lockfile --ignore-scripts
# 3. BUILDER
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY. .
# Build variables
ENV DATABASE_URL="postgresql://dummy:dummy@localhost:5432/dummy"
ENV NEXT_TELEMETRY_DISABLED=1
RUN pnpm prisma generate
ARG NEXT_PUBLIC_API_URL
ARG NEXT_PUBLIC_BASE_URL
ARG NEXT_PUBLIC_RECAPTCHA_SITE_KEY
ENV NEXT_PUBLIC_RECAPTCHA_SITE_KEY=${NEXT_PUBLIC_RECAPTCHA_SITE_KEY}
ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
ENV NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL}
# Fake keys for build
ENV TELEGRAM_BOT_TOKEN="mock_token_for_build"
# 👇 THIS LINE HAS BEEN ADDED 👇
ENV TELEGRAM_WORK_BOT_TOKEN="mock_work_token_for_build"
# It is also recommended to add admin IDs to remove the warning
ENV TELEGRAM_ADMIN_IDS="123456"
ENV NEXTAUTH_SECRET="mock_secret_for_build"
# Build in Standalone mode
ENV NEXT_TELEMETRY_DISABLED=1
RUN pnpm build
# 4. RUNNER
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
# 🟢 FIX 1: Hardcode cache paths within /app, where we'll definitely have permissions
ENV HOME=/app
ENV COREPACK_HOME=/app/.cache/corepack
ENV PNPM_HOME=/app/.cache/pnpm
# Create a user
RUN groupadd --system --gid 1001 nodejs
RUN useradd --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
# Copy prisma and config
COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma
COPY --from=builder --chown=nextjs:nodejs /app/prisma.config.ts ./
COPY start-bot.sh .
RUN chmod +x start-bot.sh
# 🟢 FIX 2: Immediately create the .cache folder along with the rest
RUN mkdir -p /app/public/uploads /app/.next/cache /app/.cache
# Grant permissions to the ENTIRE /app folder to the nextjs user (including Prisma files and new cache folders)
RUN chown -R nextjs:nodejs /app
USER nextjs
EXPOSE 3000
CMD ["./start-bot.sh"]
Docker Compose: Управління інфраструктурою
Твій docker-compose.yml — це пульт управління.
mem_limitтаmem_reservation: Це критично для VPS. Ти обмежуєш додаток, щоб він не "з'їв" усю пам'ять сервера і не спричинивOOM Killer(система не вб'є SSH чи інші сервіси).extra_hosts: Це дозволяє контейнеру бачитиlocalhostхост-машини, що дуже зручно для підключення до баз даних, які стоять поруч на VPS.healthcheck: Docker буде періодично перевіряти, чи живий твій Next.js (через запит до/api/health). Якщо сервак "завис" — Docker автоматично перезапустить контейнер.
Скрипт start-bot.sh: Чому це розумно?
У багатьох виникає питання: "Чому б не запустити бота просто окремим процесом?". Відповідь: Синхронізація.
Бот потребує нашого API, щоб налаштувати Webhook. Якщо ми запустимо бота миттєво разом з контейнером, він спробує налаштувати Webhook у момент, коли Next.js ще тільки "прогрівається" і не відповідає на запити. Результат — помилка підключення.
Як працює наш start-bot.sh:
Запуск: Він запускає сервер Next.js у фоні (
node server.js &).Очікування (Readiness Probe): Скрипт входить у цикл
for, де кожну секунду черезwgetстукає наhttp://127.0.0.1:3000/.Webhook Setup: Як тільки сервер відповів (успішно прогрузився), скрипт миттєво робить запит до твого API (
/api/telegram/setup).Результат: Бот завжди підключається до Webhook'а тільки тоді, коли сервер до нього повністю готовий.
Це ідеальний конвеєр: Сервер встав -> Бот зрозумів, куди надсилати дані.
docker-compose.yml
services:
muntai-app:
build:
context: .
dockerfile: Dockerfile
# We pass only public variables for frontend assembly
args:
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL}
NEXT_PUBLIC_BASE_URL: ${NEXT_PUBLIC_BASE_URL}
NEXT_PUBLIC_RECAPTCHA_SITE_KEY: ${NEXT_PUBLIC_RECAPTCHA_SITE_KEY}
container_name: muntai
restart: always
ports:
- "127.0.0.1:3000:3000"
extra_hosts:
- "host.docker.internal:host-gateway"
# All secrets (DB, Token, Auth) will be pulled into the container from here upon startup
env_file:
- .env
environment:
NODE_ENV: production
NODE_OPTIONS: "--max-old-space-size=1024"
mem_limit: 1536m
mem_reservation: 256m
# Healthcheck is optimized for Alpine (curl may not be available; use wget, which is available in busybox)
healthcheck:
test:
[
"CMD",
"wget",
"--no-verbose",
"--tries=1",
"--spider",
"http://localhost:3000/api/health",
]
interval: 30s
timeout: 10s
retries: 3
volumes:
- ./public/uploads:/app/public/uploads
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
Секретний інгредієнт: Скрипт start-bot.sh
Ти можеш запитати: "Чому б просто не вказати CMD ["pnpm", "start"] у Dockerfile?". Відповідь проста: Синхронізація. Нам потрібно зробити дві речі:
Запустити Next.js сервер.
Тільки після того, як сервер повністю завантажиться, відправити запит до нашого API для реєстрації Telegram Webhook-а.
Якщо спробувати зробити це одночасно, бот спробує "стукнути" у ще не завантажений Next.js, отримає помилку, і реєстрація Webhook-а провалиться. Наш скрипт вирішує це елегантно.
Розбір скрипта:
Bash
#!/bin/sh
set -e
set -e: це "запобіжник". Якщо будь-яка команда в скрипті завершиться з помилкою, весь скрипт миттєво зупиниться. Це рятує нас від ситуації, коли бот не запустився, а контейнер продовжує працювати "ніби нічого не сталося".
Bash
cleanup() {
echo "🛑 Stopping Next.js..."
kill "$NEXT_PID" 2>/dev/null || true
wait "$NEXT_PID" 2>/dev/null || true
exit 0
}
trap cleanup TERM INT
Graceful Shutdown (Коректне вимкнення): Docker надсилає сигнал
TERM(абоINT), коли ти зупиняєш контейнер. Командаtrapперехоплює цей сигнал. Без цього Docker просто "вб'є" процес, не давши йому закрити з'єднання з БД чи дописати логи. Ми ж даємо серверу шанс коректно завершити роботу.
Bash
node server.js &
NEXT_PID=$!
Ми запускаємо Next.js через
node server.js(результат нашої Standalone-збірки) і відправляємо його у фоновий режим за допомогою амперсанда&.$!зберігає PID (Process ID) цього фонового процесу. Це потрібно нам для того, щоб ми могли керувати ним (зупинити вcleanupабо дочекатися вwait).
Bash
for i in $(seq 1 30); do
if wget -q --spider http://127.0.0.1:3000/; then
...
break
fi
sleep 1
done
Readiness Probe (Перевірка готовності): Це "серце" логіки. Ми робимо 30 спроб (по одній на секунду) достукатися до нашого сервера через
wget.--spider— це режимwget, який не завантажує файл, а просто перевіряє, чи живий сервер. Це максимально легка і швидка операція.
Bash
wget -q -O - http://127.0.0.1:3000/api/telegram/setup
Реєстрація: Як тільки
wgetотримав відповідь200 OK, ми впевнені, що Next.js готовий. Ми викликаємо наш API, який реєструє Webhook у Telegram.
Bash
wait $NEXT_PID
Це фінальна команда. Вона каже скрипту: "Залишайся живим доти, доки живе процес Next.js". Як тільки Next.js впаде (наприклад, через помилку),
waitзавершиться, скрипт закриється, і Docker-контейнер автоматично зупиниться (або перезапуститься, якщо вdocker-composeстоїтьrestart: always).
Чому це професійний підхід?
Більшість початківців просто запускають pnpm start і сподіваються на краще. Ми ж створили "самодостатню систему".
Наш контейнер сам себе запускає, сам себе перевіряє, сам підключає зовнішні сервіси (Telegram) і сам акуратно завершує роботу при зупинці. Ти більше не будеш заходити на сервер, щоб "рестартнути бот" або "перезапустити Webhook". Це і є справжня магія Docker, про яку мовчать у простих туторіалах.
start-bot.sh
#!/bin/sh
set -e
# Function for graceful termination
cleanup() {
echo "🛑 Stopping Next.js..."
kill "$NEXT_PID" 2>/dev/null || true
wait "$NEXT_PID" 2>/dev/null || true
exit 0
}
# Intercept stop signals (from Docker)
trap cleanup TERM INT
echo "🚀 Starting Next.js..."
node server.js &
NEXT_PID=$!
echo "⏳ Waiting for server to start..."
# Trying to reach the server (maximum 30 seconds)
for i in $(seq 1 30); do
if wget -q --spider http://127.0.0.1:3000/; then
echo "✅ Server is up!"
# --- HERE WE PUSH THE BOT ---
echo "🤖 Setting up Telegram Webhook..."
# We're using 127.0.0.1 since we're inside a container.
# wget -q -O - prints the response to the console so you can see the result
wget -q -O - http://127.0.0.1:3000/api/telegram/setup || echo "❌ Failed to setup bot"
echo "" # just a line break for aesthetics
# -------------------------------
break
fi
sleep 1
done
echo "📡 Next.js server running with PID $NEXT_PID"
# Wait for the Next.js process to run. If it crashes, the script will terminate.
wait $NEXT_PID
Магія Standalone: Чому Docker не має сенсу без цього режиму
Коли ти запускаєш стандартний next build, Next.js створює папку .next, в яку кладе все підряд. Якщо ти просто скопіюєш це в Docker, ти отримаєш "важковаговика". Але в Next.js є режим Standalone.
Що це дає? Коли ти вмикаєш output: 'standalone', Next.js автоматично аналізує твої файли (через outputFileTracing) і збирає мінімально необхідну папку, в якій є лише той код і ті node_modules, які реально потрібні для запуску сервера.
Це дозволяє нам у нашому Dockerfile на етапі RUNNER просто скопіювати папку standalone і отримати готовий до роботи сервер без зайвого "баласту".
Розбираємо твій next.config.ts
Ось приклад файлу next.config.ts, який ми використовуємо. Давай розберемо, чому кожен рядок тут — це професійний стандарт:
TypeScript
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
// 1. Security: hide that the site is on Next.js
poweredByHeader: false,
// 2. The same "gold": create a minimal build
output: 'standalone',
experimental: {
serverActions: {
bodySizeLimit: '10mb',
},
},
// 3. Image security: allow only trusted domains
images: {
remotePatterns: [{ protocol: 'https', hostname: '**' }],
},
sassOptions: {
silenceDeprecations: ['import'],
},
// 4. Helps Next.js understand the root folder in Docker
outputFileTracingRoot: __dirname,
}
export default nextConfig
Чому це критично для нас?
output: 'standalone': Це наш ключовий гравець. Він створює папку.next/standalone. Саме з неї ми беремо сервер для Docker-контейнера. Це зменшує розмір образу з 1-2 ГБ до якихось 100-200 МБ.poweredByHeader: false: Стандартний Next.js додає в HTTP-заголовки рядокX-Powered-By: Next.js. Для безпеки краще це приховати, щоб хакери не знали, на чому написано твій сайт.outputFileTracingRoot: __dirname: В Docker-контейнері структура папок відрізняється від локальної машини. Цей параметр підказує Next.js: "Слухай, шукай залежності відносно поточної папки, а не від кореня системи". Без цього іноді виникають помилки "module not found" під час виконання.images.remotePatterns: Якщо ти в продакшені підтягуєш зображення з інших джерел (наприклад, з AWS S3 чи Google Cloud), тобі треба явно дозволити ці домени. Поставившиhostname: '', ти даєш гнучкість, але в ідеалі — краще прописати конкретні домени для максимальної безпеки.
Що це дає нам у Dockerfile?
Пам'ятаєш наш Dockerfile? Ми там писали: COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
Саме завдяки тому, що в next.config.ts стоїть output: 'standalone', ця папка standalone існує. Ми просто копіюємо її — і вуаля, наш сервер готовий. Нам навіть не треба робити pnpm install у фінальному образі.
Твій Docker-образ стає "атомарним": він містить тільки те, що має виконуватись, і нічого зайвого. Це не тільки швидко, це ще й надійно: немає зайвих файлів — немає зайвих дірок у безпеці.
Найбільша помилка новачків при деплої — це тягнути на сервер "сміття" (типу локальних node_modules, папки .next, .git або файлів, що містять секрети). В Docker-деплої ми сповідуємо принцип: "Переносимо тільки те, що потрібно для збірки".
Давай розберемося, що саме має опинитися на твоєму VPS.
.dockerignore — твій найкращий друг
Перш ніж ми скопіюємо хоч один файл, ти повинен створити у корені проєкту файл .dockerignore. Він працює так само як .gitignore, але вказує Docker, які файли не треба копіювати всередину контейнера. Якщо цього не зробити, Docker-образ буде роздуватися до гігабайтів.
Твій .dockerignore повинен виглядати ось так:
Plaintext
node_modules
.next
.git
.env
.env.local
.vscode
.idea
dist
out
build
*.log
Ти бачиш? Ми ігноруємо node_modules та .next, тому що вони будуть перестворені всередині Docker під час збірки. Це гарантує "чисту" та передбачувану архітектуру.
Список файлів для копіювання на VPS
Щоб твій проєкт успішно зібрався і запустився на сервері, тобі потрібно перенести наступний "мінімальний набір":
1. Інфраструктурні файли (Керують запуском)
Dockerfile: Твоя інструкція "як зібрати проєкт".docker-compose.yml: Твій пульт управління інфраструктурою.start-bot.sh: Скрипт, який запускає Next.js і підключає бота. Не забудь.
2. Файли проєкту (Код та конфігурації)
package.jsonтаpnpm-lock.yaml: Без них Docker не дізнається, які версії бібліотек ставити.next.config.ts: Наш файл конфігурації, де стоїтьoutput: 'standalone'.tailwind.config.ts(та інші конфіги:tsconfig.json,postcss.config.mjsтощо): Вони потрібні на етапіpnpm build.Папка
prisma/: Обов'язково! Вона міститьschema.prisma. Без неїpnpm prisma generateне знайде модель даних.Папка
src/: Твій вихідний код.Папка
public/: Твої статичні файли (картинки, іконки, robots.txt).
3. Секретний файл .env (Важлива помітка)
УВАГА! Ніколи не копіюй .env через Git. Його потрібно створити безпосередньо на VPS. Після того як ти скопіюєш усі файли (наприклад, через git pull або rsync), ти маєш створити файл .env на сервері вручну:
Bash
nano .env
Пастка "localhost": Чому твій проєкт не бачить базу даних
Коли ти запускаєш проект на своєму комп'ютері (напряму через node або pnpm dev), твій застосунок і база даних (наприклад, PostgreSQL або Redis) живуть в одному "світі" — на твоєму localhost. Вони чудово бачать один одного.
Але Docker — це "в'язниця" (в хорошому сенсі). Контейнер має власну ізольовану мережу. Коли всередині контейнера ти пишеш localhost, Docker думає, що ти звертаєшся до самого контейнера. Він шукає базу даних всередині контейнера, а її там немає — вона стоїть на твоєму VPS "зовні".
Саме тому ми і використовуємо магічний хост host.docker.internal.
Що таке host.docker.internal?
Це спеціальне ім'я, яке Docker автоматично мапить на внутрішню IP-адресу твого хост-комп'ютера (твого VPS). Це такий собі "місток", який дозволяє контейнеру визирнути назовні та постукати у двері твого сервера.
Важливо: У нашому
docker-compose.yml, який ми писали раніше, ми спеціально додали рядокextra_hosts: - "host.docker.internal:host-gateway". Це саме та команда, яка пояснює Docker, куди саме "стукати" при зверненні до цього імені.
Як налаштувати .env для продакшену
На своєму VPS, у папці з проєктом, ти створюєш файл .env (пам'ятаєш, ми не копіюємо його через Git, а створюємо прямо на сервері). Ось як мають виглядати твої конфіги для бази та Redis, щоб вони працювали через Docker:
1. PostgreSQL
Замість localhost ми вказуємо шлях до нашого хосту:
Фрагмент коду
# Було локально:
# DATABASE_URL="postgresql://muntai:password@localhost:5432/db_name"
# Стало для Docker:
DATABASE_URL="postgresql://muntai:password@host.docker.internal:5432/db_name"
2. Redis
Якщо ти використовуєш Redis, ситуація аналогічна. Твій docker-compose тепер бачить його через цей самий "місток":
Фрагмент коду
REDIS_HOST=host.docker.internal
REDIS_PORT=6379
# Якщо твій код використовує повний URL для підключення:
REDIS_HOST_URL=redis://host.docker.internal:6379
Поради для безпеки та стабільності .env
Не жорсткий код: Ніколи не пиши ці значення в
Dockerfileабоnext.config.ts. Вони мають бути тільки у файлі.envна сервері. Це дозволяє тобі швидко змінити пароль до бази або перенести її на інший сервер без перезбірки всього Docker-образу.Паролі: Переконайся, що пароль до бази даних (як і інші секрети) на сервері у файлі
.envзакритий правами доступу, щоб інші користувачі сервера не могли його прочитати:
Bashchmod 600 .envDebug: Якщо після деплою ти бачиш помилку
Connection refused(хоча база точно працює), переконайся, що твоя база даних (PostgreSQL) у своємуpostgresql.confналаштована слухати не тільки127.0.0.1, а й0.0.0.0або конкретно IP-адресу Docker-містка, інакше вона просто "не почує" контейнер.
Тепер у нас повністю готова інфраструктура:
Контейнер зібраний правильно.
Зовнішні сервіси (База, Redis) підключені.
Ми обійшли всі пастки локального середовища.
І вставити туди всі свої змінні (токен бота, DATABASE_URL, NEXTAUTH_SECRET тощо). Так твої секрети ніколи не потраплять у репозиторій.
Як зручно перенести ці файли?
Оскільки ми вже налаштували SSH доступ до твого VPS, у нас є два крутих шляхи:
Варіант А: Через Git (Професійний)
Робиш
git pushсвого проєкту в окрему гілку (або в main).На сервері заходиш у папку проєкту:
cd /var/www/ваш_домен.Робиш
git pull origin main.Створюєш
.env(якщо його ще немає).
Варіант Б: Через rsync (Швидкий) Якщо ти хочеш перенести файли напряму з локальної машини (без Git):
Bash
rsync -avz --exclude '.git' --exclude 'node_modules' --exclude '.next' . user@your-vps-ip:/var/www/ваш_домен
Ця команда відправить все, що потрібно, і сама проігнорує все зайве.
Деплой та "Гігієна" сервера: Чому наші команди такі довгі?
Коли ти бачиш команду, яка складається з 5-6 частин, розділених &&, не лякайся. Це наш спосіб сказати Docker: "Створи нове, видали старе, почисть сміття".
Розбираємо "магічну" команду деплою:
Bash
docker compose down && docker compose up -d --build && docker system prune -f && docker image prune -a -f && docker builder prune --all -f
docker compose down: Ми повністю зупиняємо контейнер і видаляємо його. Це важливо для "чистого" старту.docker compose up -d --build: Запускаємо білд з нуля (обов'язково з--build, щоб підхопити зміни в коді) і піднімаємо контейнер у фоновому режимі (-d).docker system prune -f: Видаляє всі зупинені контейнери, невикористані мережі та "данглінг-образи" (ті, що залишилися без тегів).-fозначає "force" — щоб не питав кожного разу підтвердження.docker image prune -a -f: Видаляє абсолютно всі образи, які не використовуються жодним запущеним контейнером. Це звільняє місце на диску.docker builder prune --all -f: Це найголовніше! Очищає кеш збірки. Якщо цього не робити, кеш може розростися до 10-20 ГБ.
Порада: Якщо ти впевнений у своєму коді і хочеш ще агресивніше, можна використовувати docker compose build --no-cache, але перша команда — це золота середина.
Як жити після деплою: Моніторинг та Контроль
Ти не просто "закинув і забув". Ти капітан, і тобі треба знати стан справ на борту.
Логи — твої очі: Якщо щось пішло не так (наприклад, 500 помилка), ти маєш миттєво це бачити:
Bash
docker logs -f назва_вашего_контейнеру-f(follow) тримає лог відкритим, і ти бачиш нові помилки в реальному часі.Контроль місця: Раз на місяць обов'язково перевіряй, скільки місця займає Docker:
Bash
docker system dfВона покаже, скільки місця займають образи, контейнери та кеш. Якщо бачиш, що
Build Cacheвиріс занадто сильно — сміливо робиdocker builder prune.
Оновлення БД Prisma: Спеціальна операція
Це найчастіше питання: "Я додав нове поле в schema.prisma, як оновити базу?". Ніколи не роби це безпосередньо в контейнері через docker exec під час білду. Роби це після запуску контейнера, щоб дані не полетіли.
Коли ти змінив схему і вилив код: Спочатку підніми новий контейнер.
Пуш змін у БД: Заходь всередину працюючого контейнера і кажи Prisma оновити структуру:
Bash
docker exec -it muntai pnpm dlx prisma db pushЦе команда-тріггер. Prisma порівняє твою нову схему з тим, що зараз у базі, і застосує зміни (додасть колонки, індекси тощо).
Після цього: Я наполегливо раджу зробити "чистий" перезапуск контейнера, щоб Next.js підхопив нову структуру:
Bash
docker compose down && docker compose up -d --build && docker system prune -f
Чому цей підхід кращий за "просто docker restart"?
Тому що docker restart не оновлює образ. А в нашому випадку (з багатоетапною збіркою) всі зміни коду зашиваються саме в образ. Коли ти робиш up -d --build, Docker розуміє, що код змінився, перезбирає лише необхідні шари (layers) і піднімає свіжу, актуальну версію твого проєкту.
Це гарантує, що на сервері завжди саме той код, який ти щойно протестував локально.
Фінальний акорд: Nginx як "воротар" твого проєкту
Docker-контейнер з нашим Next.js працює всередині своєї ізольованої мережі. Але хто буде спілкуватися з інтернетом, обробляти SSL-сертифікати та віддавати статику? Тут на сцену виходить Nginx.
Nginx стає нашою "проксі-панеллю". Він приймає запити з інтернету (HTTPS), "розпаковує" їх і відправляє на наш Docker-контейнер, який слухає порт 3000.
Варіант 1: Якщо ти використовуєш HestiaCP (Професійний підхід)
Панель HestiaCP — це крутий інструмент, але у неї є одна особливість: вона автоматично перезаписує конфіги. Якщо правити файли вручну — після оновлення або зміни SSL вони просто зникнуть. Тому ми робимо кастомні шаблони.
Створюємо шаблони: Створи два файли (наприклад я для себе навав їх так):
muntainextjs.tpl(для HTTP) таmuntainextjs.stpl(для HTTPS) з кодом, який ти налаштував. Це гарантує, що Nginx "знає" про наші особливі потреби: буферізацію для Server Actions та правильну роботу з/_next/static.Копіюємо їх на сервер: Закинь ці файли у папку
/usr/local/hestia/data/templates/web/nginx.Активуємо в панелі:
Заходиш в Hestia -> ВЕБ -> Редагування домену.
Розширені налаштування -> Шаблон проксі (Proxy Template).
Вибираєш свій новий
muntainextjs.
Приклади моїх шаблонів
1. Файл muntainextjs.tpl (HTTP)
server {
listen %ip%:%proxy_port%;
server_name %domain_idn% %alias_idn%;
error_log /var/log/%web_system%/domains/%domain%.error.log error;
# Include standard redirect to HTTPS if SSL is enabled
include %home%/%user%/conf/web/%domain%/nginx.forcessl.conf*;
# [FIXED] Upload limit (must match next.config.ts)
client_max_body_size 10M;
# Gzip compression
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
# 1. Handle Let's Encrypt
location /.well-known/acme-challenge/ {
alias %home%/%user%/web/%domain%/public_html/.well-known/acme-challenge/;
try_files $uri =404;
}
# 2. Proxy for system files
location ~ ^/(sitemap.xml|robots.txt)$ {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $http_host;
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 60s;
}
# 3. Main proxy for Next.js
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
# Buffer settings (helps with INVALID_MESSAGE)
proxy_busy_buffers_size 512k;
proxy_buffers 4 256k;
proxy_buffer_size 256k;
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
# DIRECT SERVING OF UPLOADS
location /uploads/ {
alias %home%/%user%/web/%domain%/public/uploads/;
expires 30d;
add_header Cache-Control "public, no-transform";
access_log off;
try_files $uri =404;
}
location /error/ {
alias %home%/%user%/web/%domain%/document_errors/;
}
location ~ /\.(?!well-known\/) {
deny all;
return 404;
}
include %home%/%user%/conf/web/%domain%/nginx.conf_*;
}
2. Файл muntainextjs.stpl (HTTPS)
server {
listen %ip%:%proxy_ssl_port% ssl;
server_name %domain_idn% %alias_idn%;
error_log /var/log/%web_system%/domains/%domain%.error.log error;
# --- SSL Directives ---
ssl_certificate %ssl_pem%;
ssl_certificate_key %ssl_key%;
ssl_stapling on;
ssl_stapling_verify on;
# [FIXED] Upload limit for images
client_max_body_size 10M;
# Gzip compression
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
# TLS 1.3 0-RTT anti-replay
if ($anti_replay = 307) { return 307 https://$host$request_uri; }
if ($anti_replay = 425) { return 425; }
# HSTS
include %home%/%user%/conf/web/%domain%/nginx.hsts.conf*;
# 1. Handle Let's Encrypt
location /.well-known/acme-challenge/ {
alias %home%/%user%/web/%domain%/public_html/.well-known/acme-challenge/;
try_files $uri =404;
}
# 2. Proxy for Sitemap and Robots
location ~ ^/(sitemap.xml|robots.txt)$ {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $http_host;
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 60s;
}
# 3. Next.js static assets optimization
location /_next/static/ {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $http_host;
expires 365d;
add_header Cache-Control "public, immutable";
}
# 4. Main proxy for Next.js
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
# Increased buffers for stable Server Actions
proxy_busy_buffers_size 512k;
proxy_buffers 4 256k;
proxy_buffer_size 256k;
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
# DIRECT SERVING OF UPLOADS
location /uploads/ {
alias %home%/%user%/web/%domain%/public/uploads/;
expires 30d;
add_header Cache-Control "public, no-transform";
access_log off;
try_files $uri =404;
}
location ~ /\.(?!well-known\/) {
deny all;
return 404;
}
include %home%/%user%/conf/web/%domain%/nginx.ssl.conf_*;
}
Важливо: Переконайся, що папка public/uploads на хост-сервері має правильні права. Виконай: chown -R 1001:1001 ./public/uploads, щоб контейнер міг вільно зберігати туди файли, які завантажують користувачі.
Все! Hestia тепер "прив'язана" до твого шаблону і не буде чіпати конфіг.
Варіант 2: Якщо ти налаштовуєш Nginx вручну (Pure VPS)
Якщо панелей немає і ти рулиш сервером через термінал, ось твій конфіг. У ньому є дві "магічні" фішки, які відрізняють аматорів від профі:
1. Агресивне кэшування (/_next/static/) Next.js додає хеші до імен файлів (наприклад, main-123456.js). Це означає, що цей файл ніколи не зміниться. Ми кажемо браузеру: "Збережи це назавжди".
Nginx
location /_next/static/ {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $http_host;
# Кэшуємо на рік, файл не зміниться (immutable)
expires 365d;
add_header Cache-Control "public, immutable";
}
2. Підтримка WebSockets (для HMR) Тобі не обов'язково для деплою, але якщо ти колись захочеш запустити девелоперську збірку на сервері, це дозволить "гарячу перезагрузку" (HMR) працювати:
Nginx
location / {
proxy_pass http://127.0.0.1:3000;
# ... інші заголовки ...
# Підтримка WebSockets
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
Чому ми додали ці складні налаштування буферів?
Ти, мабуть, помітив у конфігах оці рядки:
Nginx
proxy_busy_buffers_size 512k;
proxy_buffers 4 256k;
proxy_buffer_size 256k;
Без них, коли Next.js (особливо з Server Actions) віддає велику відповідь, Nginx може видати помилку 502 Bad Gateway або upstream sent too big header. Ці налаштування дозволяють Nginx приймати "важкі" запити від твого Docker-контейнера без стресу. Це і є "стабільність", за яку платять гроші в індустрії.
Застереження про Firewall (UFW)
Docker має особливість: він автоматично переписує правила iptables, щоб прокинути порти. Це іноді конфліктує з ufw (стандартним фаєрволом Ubuntu).
Моя порада: Якщо ти використовуєш
ufw, пам'ятай, що Docker іноді "обходить" його правила для відкритих портів. Не покладайся тільки на фаєрвол, завжди налаштовуй доступ до бази даних та Redis тільки через127.0.0.1(як ми зробили вdocker-compose.yml), щоб вони не "стирчали" в інтернет.
Тепер твій проєкт — це не просто папка з кодом, а надійна екосистема. Він ізольований, безпечний, автоматично перезапускається і легко оновлюється. Ти більше не залежиш від версій Node.js на сервері чи криво налаштованого оточення. Ти отримав справжній Production-ready стек.
