У сучасній Fullstack-розробці на Next.js 15 серверна частина застосунку більше не є звичайним генератором HTML-розмітки. Застосунок на App Router виступає як високонавантажений бекенд-сервіс, що активно взаємодіє зі складною розподіленою інфраструктурою:
Спроба ручного встановлення та координації цих сервісів на локальних робочих станціях розробників призводить до розбіжності середовищ («works on my machine») та суттєво уповільнює онбординг нових інженерів. Для усунення цієї проблеми застосовується специфікація Docker Compose (Compose Spec v2).
[Топологія локального Fullstack-стека]
Робоча станція розробника / Браузер
(HTTP: localhost:3000, MinIO UI: localhost:9001)
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Docker Network: backend-net (Ізольована мостова мережа) │
│ │
│ ┌────────────────────┐ ┌─────────────────────────┐ │
│ │ web (Next.js) │ ─────────► │ postgres (PostgreSQL) │ │
│ │ │ TCP: 5432 │ Volume: postgres_data │ │
│ │ • Server Actions │ └─────────────────────────┘ │
│ │ • Route Handlers │ │
│ │ • RSC SSR │ ─────────► ┌─────────────────────────┐ │
│ │ │ TCP: 6379 │ redis (Redis 7) │ │
│ │ │ │ Volume: redis_data │ │
│ │ │ └─────────────────────────┘ │
│ │ │ │
│ │ │ ─────────► ┌─────────────────────────┐ │
│ │ │ S3: 9000 │ minio (MinIO S3) │ │
│ │ │ │ Volume: minio_data │ │
│ │ │ └────────────▲────────────┘ │
│ └────────────────────┘ │ │
│ ┌────┴────────────┐ │
│ │ minio-init │ │
│ │ Створення бакетів│ │
│ └─────────────────┘ │
└─────────────────────────────────────────────────────────────────┘Головними інженерними викликами під час оркестрації такого кластера є виявлення сервісів (Service Discovery) та детермінований порядок ініціалізації з перевіркою готовності (Startup Ordering & Healthchecks).
Усередині мостової мережі (bridge network) Docker Compose запускає вбудований DNS-сервер (127.0.0.11), який автоматично транслює імена сервісів (postgres, redis, minio) у їхні динамічні віртуальні IP-адреси. Це звільняє конфігурацію від жорсткої прив'язки до IP-адрес робочої станції.
Ментальна модель Docker Compose спирається на концепцію декларативного графа залежностей із верифікацією готовності (Health-Conditioned Dependency Graph).
Поширеною помилкою є припущення про те, що стандартна директива depends_on: ['postgres'] гарантує готовність бази даних приймати підключення. На практиці Docker вважає контейнер «запущеним» у ту саму мілісекунду, коли ядро ОС виділяє процес. У цей момент демон PostgreSQL лише починає зчитувати файли конфігурації, перевіряти журнали WAL та відкривати сокети. Якщо Next.js стартує водночас і запускає міграції Prisma або Drizzle, застосунок аварійно завершується з помилкою ECONNREFUSED.
Для ліквідації цього стану гонки (Race Condition) Compose комбінує дві директиви:
healthcheck: періодична внутрішня проба життєздатності всередині сервісу (наприклад, утилітою pg_isready для Postgres або redis-cli ping для Redis). Сервіс отримує статус healthy виключно тоді, коли демон готовий обслуговувати реальні клієнтські підключення.depends_on з умовою condition: service_healthy: призупиняє запуск залежного сервісу web доти, доки цільові сервіси інфраструктури не пройдуть свої healthchecks.Запуск кластера [docker compose up]
│
├─────────────────────────────────┬─────────────────────────────────┐
▼ ▼ ▼
[Postgres: Старт демона] [Redis: Виділення пам'яті] [MinIO: Старт S3 API]
│ │ │
[pg_isready: Відмова...] [redis-cli ping: PONG!] [curl /minio/health: 200]
│ │ │
[pg_isready: 200 OK!] ▼ ▼
│ Статус: HEALTHY Статус: HEALTHY
▼ │ │
Статус: HEALTHY │ │
│ │ │
└─────────────────────────────────┼─────────────────────────────────┘
│ Усі умови готовності виконані
▼
[minio-init: Створення S3-бакетів]
│ Код завершення 0
▼
[web (Next.js): Запуск застосунку]
• Виконання міграцій схеми Prisma
• Підключення пулів до Postgres та Redis
• Обслуговування клієнтського HTTP-трафікуДруга критична концепція — дворівнева адресація S3 (Dual-Endpoint S3 Resolution). Оскільки MinIO використовується і бекенд-кодом Next.js (усередині мережі Docker), і клієнтським браузером (на хост-машині), конфігурація повинна чітко розмежовувати внутрішні виклики SDK (http://minio:9000) та зовнішні публічні Presigned URLs для браузера (http://localhost:9000).
Ключові сутності оркестрації:
Новий розробник клонує репозиторій та виконує docker compose up -d. За кілька секунд розгортається повноцінне оточення: база даних із застосованою схемою, кеш Redis та налаштовані бакети S3 без потреби встановлювати локальні демони баз даних чи реєструвати AWS-акаунт.
У раннерах GitHub Actions перед запуском тестів Playwright піднімається ідентичний стек через Docker Compose. Тести виконуються проти реальної реляційної СУБД та справжнього сховища S3, усуваючи похибки емуляторів чи моків.
Для замовників із суворими вимогами до безпеки даних (заборона виходу в закордонні хмари AWS чи GCP) цей стек розгортається як автономна виробнича інфраструктура на власних виділених серверах підприємства.
Створимо еталонний маніфест compose.yaml, який об'єднує чотири сервіси з перевірками працездатності, постійними томами (Volumes) та sidecar-ініціалізацією сховища S3:
Створення ізольованої мостової мережі backend-net та виділення іменованих томів.
Паралельний запуск Postgres, Redis та MinIO з очікуванням проходження перевірок готовності.
Виконання контейнера minio/mc: конфігурація аліаса, створення бакета app-uploads та налаштування публічного доступу.
Контейнер web запускається після успішного проходження перевірок і прив'язує порт 3000 до хоста.
compose.yaml#Згідно зі специфікацією Compose v2 (де директива version: застаріла):
name: agenticlab-fullstack
services:
# ── 1. СУБД POSTGRESQL 17 ──
postgres:
image: postgres:17-alpine
container_name: fullstack-postgres
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres_dev_secret}
POSTGRES_DB: ${POSTGRES_DB:-agenticlab_db}
ports:
# Прокидання порту на хост для підключення через DBeaver / DataGrip
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- backend-net
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-agenticlab_db}"]
interval: 5s
timeout: 5s
retries: 5
start_period: 5s
# ── 2. IN-MEMORY КЕШ REDIS 7 ──
redis:
image: redis:7.4-alpine
container_name: fullstack-redis
restart: unless-stopped
command: ["redis-server", "--appendonly", "yes", "--requirepass", "${REDIS_PASSWORD:-redis_dev_secret}"]
ports:
- "6379:6379"
volumes:
- redis_data:/data
networks:
- backend-net
healthcheck:
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD:-redis_dev_secret}", "ping"]
interval: 5s
timeout: 3s
retries: 5
start_period: 3s
# ── 3. ОБ'ЄКТНЕ СХОВИЩЕ MINIO S3 ──
minio:
image: minio/minio:RELEASE.2024-11-07T00-52-20Z
container_name: fullstack-minio
restart: unless-stopped
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${S3_ACCESS_KEY:-minio_admin}
MINIO_ROOT_PASSWORD: ${S3_SECRET_KEY:-minio_secret_password}
ports:
- "9000:9000" # S3 REST API
- "9001:9001" # MinIO Web Console UI
volumes:
- minio_data:/data
networks:
- backend-net
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 5s
timeout: 5s
retries: 5
start_period: 5s
# ── 4. ІНІЦІАЛІЗАТОР БАКЕТІВ S3 (SIDECAR) ──
minio-init:
image: minio/mc:RELEASE.2024-11-05T11-41-28Z
container_name: fullstack-minio-init
depends_on:
minio:
condition: service_healthy
networks:
- backend-net
entrypoint: >
/bin/sh -c "
echo 'Підключення до MinIO...';
/usr/bin/mc alias set myminio http://minio:9000 ${S3_ACCESS_KEY:-minio_admin} ${S3_SECRET_KEY:-minio_secret_password};
echo 'Створення бакета app-uploads...';
/usr/bin/mc mb myminio/app-uploads --ignore-existing;
echo 'Встановлення публічної політики читання...';
/usr/bin/mc anonymous set download myminio/app-uploads;
echo 'Ініціалізація MinIO завершена.';
exit 0;
"
# ── 5. ЗАСТОСУНОК NEXT.JS 15 (WEB) ──
web:
build:
context: .
dockerfile: Dockerfile
target: runner
container_name: fullstack-web
restart: unless-stopped
ports:
- "3000:3000"
environment:
NODE_ENV: production
PORT: 3000
# Внутрішні DNS-імена в мережі backend-net
DATABASE_URL: "postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres_dev_secret}@postgres:5432/${POSTGRES_DB:-agenticlab_db}?schema=public"
REDIS_URL: "redis://:${REDIS_PASSWORD:-redis_dev_secret}@redis:6379"
# S3 конфігурація для внутрішніх серверних звернень
S3_ENDPOINT: "http://minio:9000"
# S3 адреса для браузера (Presigned URLs)
NEXT_PUBLIC_S3_PUBLIC_URL: "http://localhost:9000"
S3_BUCKET: "app-uploads"
S3_ACCESS_KEY: ${S3_ACCESS_KEY:-minio_admin}
S3_SECRET_KEY: ${S3_SECRET_KEY:-minio_secret_password}
S3_REGION: "us-east-1"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
minio-init:
condition: service_completed_successfully
networks:
- backend-net
networks:
backend-net:
driver: bridge
volumes:
postgres_data:
name: fullstack_postgres_data
redis_data:
name: fullstack_redis_data
minio_data:
name: fullstack_minio_dataСерверний код спілкується з MinIO за адресою http://minio:9000, але підписані посилання мають посилатися на http://localhost:9000, щоб браузер міг завантажити файл безпосередньо:
import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
// Серверний клієнт для викликів усередині мережі Docker (Next.js -> MinIO)
export const s3ServerClient = new S3Client({
region: process.env.S3_REGION || 'us-east-1',
endpoint: process.env.S3_ENDPOINT || 'http://minio:9000',
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY || '',
secretAccessKey: process.env.S3_SECRET_KEY || '',
},
// Критично для MinIO: формування шляху path-style (http://host/bucket), а не субдомену
forcePathStyle: true,
});
/**
* Генерація безпечного попередньо підписаного посилання для завантаження файлу з браузера
*/
export async function createPresignedUploadUrl(
fileKey: string,
contentType: string,
expiresInSeconds = 300
): Promise<{ uploadUrl: string; finalPublicUrl: string }> {
const command = new PutObjectCommand({
Bucket: process.env.S3_BUCKET || 'app-uploads',
Key: fileKey,
ContentType: contentType,
});
// Отримуємо внутрішній Presigned URL
const rawUrl = await getSignedUrl(s3ServerClient, command, {
expiresIn: expiresInSeconds,
});
// Замінюємо внутрішній хост minio:9000 на публічний NEXT_PUBLIC_S3_PUBLIC_URL
const publicOrigin = process.env.NEXT_PUBLIC_S3_PUBLIC_URL || 'http://localhost:9000';
const internalOrigin = process.env.S3_ENDPOINT || 'http://minio:9000';
const uploadUrl = rawUrl.replace(internalOrigin, publicOrigin);
const finalPublicUrl = `${publicOrigin}/${process.env.S3_BUCKET}/${fileKey}`;
return { uploadUrl, finalPublicUrl };
}Розробник вказує у compose.yaml залежність depends_on: [postgres]. При запуску docker compose up контейнер web падає з помилкою PrismaClientInitializationError: Can't reach database server at postgres:5432.
Причина: Припущення, що depends_on очікує повної готовності сервера баз даних.
Реальність у продакшені: Застосунок запускається раніше, ніж демон Postgres починає приймати TCP-з'єднання.
# ❌ ПОМИЛКА: Перевіряється лише факт створення процесу, а не готовність
depends_on:
- postgres
# ✅ ПРАВИЛЬНО: Очікування успішного результату перевірки healthcheck
depends_on:
postgres:
condition: service_healthyБраузер відправляє файл на отримане посилання: PUT http://minio:9000/app-uploads/photo.jpg та падає з помилкою net::ERR_NAME_NOT_RESOLVED.
Причина: AWS SDK генерує посилання на основі параметра endpoint клієнта S3Client, ігноруючи межі мережі хоста.
Реальність у продакшені: Браузер на хост-машині не має доступу до DNS-імені minio. Посилання повинно вести на localhost:9000.
// ❌ ПОМИЛКА: Посилання вказує на внутрішнє ім'я Docker
const uploadUrl = await getSignedUrl(s3Client, command); // поверне http://minio:9000/...
// ✅ ПРАВИЛЬНО: Підміна внутрішнього хоста на публічну адресу
const uploadUrl = rawUrl.replace(process.env.S3_ENDPOINT, process.env.NEXT_PUBLIC_S3_PUBLIC_URL);Запити до MinIO завершуються помилкою ENOTFOUND app-uploads.minio.
Причина: За замовчуванням AWS SDK використовує адресацію типу virtual-hosted style (http://bucket-name.s3.amazonaws.com).
Реальність у продакшені: Клієнт звертається до піддомену app-uploads.minio, який не існує в DNS Docker. MinIO вимагає path-style адресації (http://minio:9000/app-uploads).
// ❌ ПОМИЛКА: forcePathStyle вимкнено за замовчуванням
const client = new S3Client({ endpoint: 'http://minio:9000' });
// ✅ ПРАВИЛЬНО: Примусовий path-style
const client = new S3Client({
endpoint: 'http://minio:9000',
forcePathStyle: true,
});Використання папки ./data/postgres:/var/lib/postgresql/data замість іменованого тому postgres_data:/var/lib/postgresql/data.
Причина: Бажання бачити файли бази безпосередньо у провіднику файлової системи.
Реальність у продакшені: Колосальне падіння швидкодії через трансляцію файлових систем WSL2/APFS, конфлікти прав доступу та високий ризик пошкодження журналів PostgreSQL WAL.
# ❌ АНТИПАТЕРН: Host bind mount для СУБД
volumes:
- ./local-db-data:/var/lib/postgresql/data
# ✅ ПРАВИЛЬНО: Іменований том Docker із нативною швидкодією Linux
volumes:
- postgres_data:/var/lib/postgresql/dataСтворення файлу .env із паролями для Postgres, ключем Redis та обліковими записами MinIO.
Виконання docker compose up -d. Демон піднімає міст backend-net та монтує томи.
Compose блокує старт Next.js до отримання статусу healthy від СУБД та кешу.
Контейнер minio-init створює бакет app-uploads та налаштовує публічну політику download.
Контейнер web починає приймати запити на порту 3000, підключившись до готових баз даних.