Ідеальний деплой Next.js з Docker: Повний гайд для VPS (забудь про PM2)

Ідеальний деплой Next.js з Docker: Повний гайд для VPS (забудь про PM2)

Ідеальний деплой 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 розкривається на повну:

  1. Ізоляція файлової системи: Додаток у Docker працює у власному замкненому середовищі. Навіть якщо хакер знайде вразливість у твоєму Next.js коді і зможе виконати команду на сервері, він опиниться всередині контейнера. Він не матиме доступу до кореневої системи твого VPS, до баз даних інших проєктів чи SSL-сертифікатів Nginx.

  2. Обмеження ресурсів: PM2 може дозволити одному процесу "з'їсти" всю оперативну пам'ять сервера (memory leak), поклавши взагалі всі сайти на машині. У Docker ти можеш жорстко сказати: "Цей контейнер має право використовувати максимум 1 ГБ RAM і 50% процесора".

  3. Ізоляція мережі: Контейнери можуть спілкуватися між собою через закриті віртуальні мережі, які взагалі не "дивляться" назовні. Базу даних (наприклад, 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.

  1. Додаємо офіційний 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
  1. Додаємо репозиторій у список джерел:

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 не займав зайвого місця і працював прогнозовано, давай створимо конфігураційний файл, який обмежить розмір логів (це часто забувають, і логи забивають весь диск):

  1. Створи файл /etc/docker/daemon.json:

Bash

sudo nano /etc/docker/daemon.json
  1. Встав туди ці налаштування:

JSON

{
  "log-driver": "json-file",
  "log-opts": {
    "max-size": "10m",
    "max-file": "3"
  },
  "default-ulimits": {
    "nofile": {
      "Name": "nofile",
      "Hard": 65536,
      "Soft": 65536
    }
  }
}

Це обмежить кожен лог-файл до 10 МБ і залишить тільки 3 останні копії.

  1. Перезапусти 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

Що ми маємо зараз:

  1. Найактуальніша версія Docker та Docker Compose.

  2. Конфігурацію, яка не "з'їсть" весь диск логами.

  3. Безпечний доступ до Docker без постійного введення sudo.

Архітектура нашого рішення — Dockerfile, Compose та Telegram-бот

Тепер переходимо до самого серця нашої системи. Щоб зробити деплой максимально швидким і безпечним, ми використовуємо підхід Multi-stage build (багатоетапна збірка).

Dockerfile: Чому це "золотий стандарт"

Твій Dockerfile — це три окремі "сцени" в одному файлі. Чому це круто?

  1. deps: Ми встановлюємо всі залежності (node_modules).

  2. builder: Ми збираємо (build) проєкт, маючи всі залежності.

  3. 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:

  1. Запуск: Він запускає сервер Next.js у фоні (node server.js &).

  2. Очікування (Readiness Probe): Скрипт входить у цикл for, де кожну секунду через wget стукає на http://127.0.0.1:3000/.

  3. Webhook Setup: Як тільки сервер відповів (успішно прогрузився), скрипт миттєво робить запит до твого API (/api/telegram/setup).

  4. Результат: Бот завжди підключається до 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?". Відповідь проста: Синхронізація. Нам потрібно зробити дві речі:

  1. Запустити Next.js сервер.

  2. Тільки після того, як сервер повністю завантажиться, відправити запит до нашого 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

Чому це критично для нас?

  1. output: 'standalone': Це наш ключовий гравець. Він створює папку .next/standalone. Саме з неї ми беремо сервер для Docker-контейнера. Це зменшує розмір образу з 1-2 ГБ до якихось 100-200 МБ.

  2. poweredByHeader: false: Стандартний Next.js додає в HTTP-заголовки рядок X-Powered-By: Next.js. Для безпеки краще це приховати, щоб хакери не знали, на чому написано твій сайт.

  3. outputFileTracingRoot: __dirname: В Docker-контейнері структура папок відрізняється від локальної машини. Цей параметр підказує Next.js: "Слухай, шукай залежності відносно поточної папки, а не від кореня системи". Без цього іноді виникають помилки "module not found" під час виконання.

  4. 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

  1. Не жорсткий код: Ніколи не пиши ці значення в Dockerfile або next.config.ts. Вони мають бути тільки у файлі .env на сервері. Це дозволяє тобі швидко змінити пароль до бази або перенести її на інший сервер без перезбірки всього Docker-образу.

  2. Паролі: Переконайся, що пароль до бази даних (як і інші секрети) на сервері у файлі .env закритий правами доступу, щоб інші користувачі сервера не могли його прочитати:
    Bash

    chmod 600 .env
    
  3. Debug: Якщо після деплою ти бачиш помилку Connection refused (хоча база точно працює), переконайся, що твоя база даних (PostgreSQL) у своєму postgresql.conf налаштована слухати не тільки 127.0.0.1, а й 0.0.0.0 або конкретно IP-адресу Docker-містка, інакше вона просто "не почує" контейнер.

Тепер у нас повністю готова інфраструктура:

  • Контейнер зібраний правильно.

  • Зовнішні сервіси (База, Redis) підключені.

  • Ми обійшли всі пастки локального середовища.

І вставити туди всі свої змінні (токен бота, DATABASE_URL, NEXTAUTH_SECRET тощо). Так твої секрети ніколи не потраплять у репозиторій.

Як зручно перенести ці файли?

Оскільки ми вже налаштували SSH доступ до твого VPS, у нас є два крутих шляхи:

Варіант А: Через Git (Професійний)

  1. Робиш git push свого проєкту в окрему гілку (або в main).

  2. На сервері заходиш у папку проєкту: cd /var/www/ваш_домен.

  3. Робиш git pull origin main.

  4. Створюєш .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
  1. docker compose down: Ми повністю зупиняємо контейнер і видаляємо його. Це важливо для "чистого" старту.

  2. docker compose up -d --build: Запускаємо білд з нуля (обов'язково з --build, щоб підхопити зміни в коді) і піднімаємо контейнер у фоновому режимі (-d).

  3. docker system prune -f: Видаляє всі зупинені контейнери, невикористані мережі та "данглінг-образи" (ті, що залишилися без тегів). -f означає "force" — щоб не питав кожного разу підтвердження.

  4. docker image prune -a -f: Видаляє абсолютно всі образи, які не використовуються жодним запущеним контейнером. Це звільняє місце на диску.

  5. 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 під час білду. Роби це після запуску контейнера, щоб дані не полетіли.

  1. Коли ти змінив схему і вилив код: Спочатку підніми новий контейнер.

  2. Пуш змін у БД: Заходь всередину працюючого контейнера і кажи 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 вони просто зникнуть. Тому ми робимо кастомні шаблони.

  1. Створюємо шаблони: Створи два файли (наприклад я для себе навав їх так): muntainextjs.tpl (для HTTP) та muntainextjs.stpl (для HTTPS) з кодом, який ти налаштував. Це гарантує, що Nginx "знає" про наші особливі потреби: буферізацію для Server Actions та правильну роботу з /_next/static.

  2. Копіюємо їх на сервер: Закинь ці файли у папку /usr/local/hestia/data/templates/web/nginx.

  3. Активуємо в панелі:

    • Заходиш в 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 стек.

Напишіть мені
Будь ласка, заповніть форму нижче, щоб розпочати спілкування зі мною.

Цей сайт захищено reCAPTCHA. Застосовуються Політика конфіденційності та Умови використання Google.