База данных и общий compose
В прошлой лабораторной мы подняли Fastify-сервер с парой маршрутов, которые работали с in-memory массивом пользователей. Сейчас же мы:
- подключим к backend настоящую базу данных — PostgreSQL через ORM Prisma;
- перепишем in-memory ручки на работу с БД и применим первую миграцию;
- упакуем backend в Docker (multi-stage Dockerfile);
- соберём общий compose на основе механизма override’ов: один базовый файл и две вариации (
dev/prod); - поставим перед всем этим NGINX как единую точку входа и Scalar как UI для просмотра OpenAPI.
К концу лабораторной у нас одной командой будет подниматься вся информационная система — frontend, backend, БД, NGINX и Scalar в одной сети.
Подключение Prisma и описание схемы
Взаимодействовать с базой будем не путём прямых SQL-запросов, как вы это делали раньше, а через ORM.
В нашем проекте будем использовать Prisma ORM — один из самых стабильных и зрелых вариантов в сообществе. Есть и другие варианты, например, Drizzle. Он предлагает более высокую скорость выполнения операций и возможность низкоуровневого формирования запросов, что обеспечивает больший контроль. Но мы пока действуем по принципу наименьшего сопротивления — Prisma обладает более обширным сообществом и не требует углублённой конфигурации при первоначальном запуске.
package.json явно зафиксируйте мажорную версию 6 для prisma и @prisma/client. Если рядом со схемой автоматически создастся файл prisma.config.ts — его нужно удалить.Из папки backend/ устанавливаем Prisma и инициализируем структуру:
# фиксируем мажорную версию Prisma 6 — на v7 поведение генератора и output ломает текущую инструкцию
npm i prisma@^6.19 @prisma/client@^6.19
npx prisma init --datasource-provider postgresqlЭта команда создаст:
prisma/schema.prisma— файл схемы базы;.envрядом с ним (его можно сразу удалить — мы будем хранить переменные окружения в общем.envкорня проекта).
Дополним package.json ещё парой Prisma-скриптов:
{
...
"scripts": {
"dev": "tsx src/server.ts",
"build": "tsc",
"start": "node dist/server.js",
"db:generate": "prisma generate",
"db:deploy": "prisma migrate deploy"
}
...
}Теперь поправим автоматически созданный schema.prisma. Основная строчка, которая нас интересует — output внутри блока generator client. По умолчанию там появляется значение по типу src/generated/..., однако после сборки образа в production код генерируется в директорию dist, и все импорты сломаются.
Поэтому сделаем небольшие изменения:
- в качестве генератора установим просто
prisma-clientвместоprisma-client-js; - в поле
outputустановим значение, немного отличающееся от стандартного —../src/generated/prisma.
output в принципе, и продолжил бы работать стандартный import { PrismaClient } from '@prisma/client', но начиная с v7 в Prisma она станет обязательной, поэтому лучше подготовиться к этому заранее.generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// Сущность User
model User {
id String @id @default(cuid())
email String @unique
name String?
createdAt DateTime @default(now())
}Ключевое слово model используется для обозначения сущностей моделей данных, про которые мы с вами разговаривали на 2 курсе в рамках дисциплины «Проектирование баз данных». Здесь специально оставлена только одна сущность User — этого достаточно, чтобы запустить миграции и убедиться в работоспособности связки. Room и Booking для итогового задания вы будете описывать самостоятельно по этому образцу.
После этого нам необходимо сделать первую миграцию, которая сгенерирует описание структуры БД и перечень всех изменений. Но делать мы это будем только после того, как поднимем сам контейнер с БД — к этому пункту мы вернёмся чуть позже.
Prisma-плагин для Fastify
Подключим Prisma в Fastify через отдельный плагин — так клиент создаётся один раз на всё приложение, аккуратно закрывается на shutdown и доступен во всех маршрутах через app.prisma:
import fp from 'fastify-plugin'
import { PrismaClient } from '../generated/prisma/client.js'
// Расширяем типизацию Fastify: после регистрации плагина у экземпляра появится свойство prisma.
declare module 'fastify' {
interface FastifyInstance {
prisma: PrismaClient
}
}
// Fastify-плагин, который создаёт один экземпляр PrismaClient и подключает его ко всему приложению.
export default fp(async (app) => {
const prisma = new PrismaClient()
// decorate делает prisma доступным как app.prisma во всех маршрутах и хуках.
app.decorate('prisma', prisma)
// Хук onClose вызывает $disconnect, когда сервер останавливается, чтобы закрыть соединение с БД.
app.addHook('onClose', async (inst) => {
await inst.prisma.$disconnect()
})
})Теперь добавим регистрацию плагина в app.ts и перепишем in-memory ручки на работу с БД. Изменения точечные — основной каркас buildApp() (helmet, cors, rate-limit, swagger, error handlers, notFoundHandler) остаётся ровно тем же, что в лабе про Fastify. Меняются только три вещи:
1. Импорт и регистрация Prisma-плагина. Добавляем импорт и регистрируем плагин после инфраструктурных плагинов, но до маршрутов:
import prismaPlugin from './plugins/prisma.js'
// внутри buildApp(), после await app.register(swagger, ...):
await app.register(prismaPlugin)2. Удаляем массив users из памяти. Старая константа
const users: User[] = [
{ id: 'u-1', email: 'alice@example.com' },
{ id: 'u-2', email: 'bob@example.com' },
]больше не нужна — данные теперь живут в БД.
3. Хендлеры /api/users и /api/health. Сами схемы маршрутов не меняются (в /api/health дополнительно появляется ответ 503), а вот тела хендлеров заменяем на работу с Prisma:
app.get('/api/users', { schema: /* как было в лабе 06 */ },
async () => app.prisma.user.findMany({ select: { id: true, email: true } })
)
app.get(
'/api/health',
{
schema: {
operationId: 'health',
tags: ['System'],
summary: 'Health/Readiness',
description: 'Проверяет, что процесс жив и база данных отвечает.',
response: {
200: { description: 'Ready', content: { 'application/json': { schema: Health } } },
503: { description: 'Temporarily unavailable', content: { 'application/problem+json': { schema: ProblemDetails } } },
},
},
},
async (_req, reply) => {
try {
// Если SELECT 1 прошёл — сервис готов.
await app.prisma.$queryRaw`SELECT 1`
return { ok: true } as Health
} catch {
// Возвращаем 503, чтобы балансировщик мог вывести инстанс из ротации.
reply.code(503).type('application/problem+json').send({
type: 'https://example.com/problems/dependency-unavailable',
title: 'Service Unavailable',
status: 503,
detail: 'Database ping failed',
instance: '/api/health',
} satisfies ProblemDetails)
}
}
)server.ts и types.ts остаются без изменений — graceful shutdown сам корректно вызовет prisma.$disconnect() через onClose-хук плагина, а схемы User/Health/ProblemDetails уже подходят и для in-memory, и для Prisma.
Dockerfile для бэкенда
В лабораторной про Docker мы написали multi-stage Dockerfile для фронтенда. Теперь по той же схеме соберём backend — несколько стадий: установка зависимостей, сборка TS, dev-режим (для compose) и облегчённый prod-образ.
FROM node:24-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm \
npm i --no-audit --no-fund
FROM node:24-alpine AS build
WORKDIR /app
ENV NODE_ENV=development
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npx prisma generate --schema=prisma/schema.prisma
RUN npm run build
# Оставляем только runtime-зависимости
RUN npm prune --omit=dev
FROM node:24-alpine AS dev
WORKDIR /app
ENV NODE_ENV=development
COPY --from=deps /app/node_modules ./node_modules
COPY . .
CMD ["sh","-lc","npx prisma generate --schema=prisma/schema.prisma && npx prisma migrate deploy || true; npx tsx src/server.ts"]
FROM node:24-alpine AS prod
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY --from=build /app/prisma ./prisma
COPY --from=build /app/src/generated/prisma ./dist/generated/prisma
USER node
EXPOSE 3000
CMD ["node","dist/server.js"]Команды npx prisma generate в сочетании с migrate deploy призваны сгенерировать типы, через которые мы оперируем как объектами в коде в соответствии со схемой БД, и применить ранее созданные миграции.
Итоговая структура проекта
После всех этих манипуляций общая структура нашего проекта должна выглядеть примерно следующим образом:
-
-
- schema.prisma
-
- server.ts
- …
- Dockerfile
- …
-
-
-
- nginx.conf
-
- …
- Dockerfile
- .dockerignore
- vite.config.ts
- …
-
-
- nginx.conf
- .env
- compose.base.yaml
- compose.dev.yaml
- compose.prod.yaml
frontend/ уже есть свой nginx/nginx.conf — он описывает раздачу статики SPA в prod-режиме (мы добавили его в лабе про Docker). А корневой nginx/nginx.conf отвечает за dev-режим, где он работает как единая точка входа и проксирует трафик на frontend, backend и Scalar. Это два разных файла с разными задачами — не путайте их между собой.Общий compose: базовый файл
Начнём с того, что инициализируем базовый файл. Будем основываться на концепции override’ов, которая упоминалась в лабораторной про Docker: один базовый compose-файл с общими настройками и несколько override’ов под конкретное окружение.
# БАЗОВЫЕ ЯКОРЯ
x-env-file: &env_file [.env]
x-db-env: &db_env
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
x-fastify-env: &fastify_env
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?schema=public
PORT: "3000"
x-db-healthy: &db_healthy
depends_on:
db:
condition: service_healthy
services:
db:
image: postgres:17-alpine
env_file: *env_file
environment: *db_env
volumes: [ "db_data:/var/lib/postgresql/data" ]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 5s
timeout: 3s
retries: 10
backend:
<<: *db_healthy
env_file: *env_file
build:
context: ./backend
dockerfile: Dockerfile
# target задаётся в override-файлах (dev/prod)
environment: *fastify_env
expose: ["3000"]
scalar:
# для учебного стенда оставляем :latest — на проде лучше пинить конкретный SHA-дайджест
image: scalarapi/api-reference:latest
environment:
# URL относительный, редирект идёт через тот же NGINX
API_REFERENCE_CONFIG: >
{"layout":"modern","theme":"purple",
"sources":[{"name":"API","url":"/openapi.json"}]}
<<: *db_healthy
expose: ["8080"]
volumes:
db_data:С файлом .env мы уже подробно разбирались в лабораторной про запуск проекта (там его читал Vite через import.meta.env). Здесь тот же .env будет использоваться docker compose и пробрасываться сразу в несколько контейнеров — Postgres, backend и Scalar. Не забудьте оставить его в .gitignore.
.env.example, в котором содержатся описание и пример всех конфигурационных переменных, поддерживаемых проектом. А в системах автоматической сборки и тестирования (CI) они задаются при помощи специальных инструментов платформы, например, GitHub Secrets.Для нашего проекта стандартный .env файл может выглядеть примерно следующим образом:
POSTGRES_DB=example
POSTGRES_USER=exampleuser
POSTGRES_PASSWORD=examplepassword.env.dev, .env.prod и др. Их также можно переназначать в зависимости от того, с каким файлом мы работаем — compose.dev.yml, compose.prod.yml, …Dev-вариация и общий NGINX
Теперь, когда переменные среды сконфигурированы, добавим нашу dev-вариацию:
services:
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
target: dev
expose: ["5173"]
volumes:
- ./frontend:/app
- /app/node_modules
environment:
- CHOKIDAR_USEPOLLING=true
nginx:
image: nginx:1.29-alpine
depends_on:
- frontend
- backend
- scalar
ports: ["80:80"]
volumes:
- ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro
backend:
build:
context: ./backend
dockerfile: Dockerfile
target: dev
volumes:
- ./backend/prisma:/app/prisma
- /app/node_modulesЗдесь монтируется (т.е. папка / файл, который находится на хосте, и ссылка на него переносится внутрь самого контейнера) файл nginx.conf, который мы ещё не создавали. Это файл конфигурации веб-сервера NGINX — его задача переадресовывать запросы фронтенда и бэкенда из их внутренней сети на стандартный порт 80 (и 443), которые по умолчанию используются для обработки сетевых запросов. Также с его помощью можно балансировать нагрузку, ограничивать спам запросами и проводить другие настройки сетевой конфигурации.
Сам файл размещаем в директории nginx, заранее созданной в корне:
# --- Auto-Upgrade для WebSocket
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
# --- Upstreams
upstream backend_upstream { server backend:3000; keepalive 32; }
upstream vite_upstream { server frontend:5173; keepalive 32; }
upstream scalar_upstream { server scalar:8080; keepalive 8; }
server {
listen 80;
server_name _;
# --- gzip: сжатие файлов при передаче
gzip on;
gzip_min_length 256;
gzip_types text/plain text/css text/javascript application/javascript application/json application/xml application/xml+rss image/svg+xml;
gzip_vary on;
proxy_intercept_errors off;
# ===== API -> backend =====
location ^~ /api/ {
proxy_pass http://backend_upstream;
proxy_http_version 1.1;
proxy_set_header Host $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_read_timeout 300s;
proxy_buffering off;
}
# openapi
location = /openapi.json { proxy_pass http://backend_upstream/openapi.json; }
# ===== Scalar UI (документация) =====
location ^~ /docs/ {
proxy_pass http://scalar_upstream/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_read_timeout 120s;
}
# ===== Vite dev + HMR =====
location / {
proxy_pass http://vite_upstream;
proxy_http_version 1.1;
proxy_set_header Host $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;
# WebSocket для HMR
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 300s; # длинные WS-сессии
proxy_buffering off; # не буферим dev-потоки
}
}server.proxy в vite.config.ts, чтобы во время разработки /api/* уходили на локально запущенный backend. Когда вы поднимаете проект через compose, NGINX берёт эту функцию на себя — vite-proxy тогда не используется и не мешает. В обоих режимах фронтенд продолжает обращаться к одному и тому же относительному пути /api/....Запуск и первая миграция
Шаг 1 — Поднять стенд
Чтобы запустить проект, достаточно ввести:
docker compose -f compose.base.yaml -f compose.dev.yaml --env-file .env up -d --builddocker compose— запуск самого compose;-f compose.base.yaml— наш файл с базовой конфигурацией;-f compose.dev.yaml— файл, который перезаписывает / расширяет базовую конфигурацию;--env-file .env— путь и имя нашего файла с переменными среды;up -d --build— сборка контейнеров и тихий запуск контейнера.
TLS..., включите VPN и повторите попытку сборки (--build) заново. В последнее время достаточно часто наблюдаются сбои при попытке обратиться к серверам Prisma с российского IP.Шаг 2 — Зайти внутрь контейнера backend
После первого запуска не забываем применить первую миграцию, иначе backend нормально не запустится. Для этого заходим во вкладку Exec внутри контейнера backend:

Или вводим в командной строке на хост-машине команду, чтобы зайти в shell внутри конкретного контейнера: docker compose exec backend sh (запускать необходимо из той директории, где у нас находятся compose-файлы. sh вместо bash — потому что node:24-alpine в образе bash не содержит).
Шаг 3 — Применить миграцию
npx prisma migrate dev --name init --schema=prisma/schema.prismacompose.dev.yaml в секции volumes у backend есть строчка - ./backend/prisma:/app/prisma. Благодаря этому маунту папка prisma пробрасывается внутрь контейнера, и все миграции, созданные там, появляются и в локальной файловой системе хоста. Не забудьте закоммитить полученный каталог prisma/migrations/ в git — он понадобится при деплое на Render, иначе там просто нечего будет применять.Настройка prod-конфигурации
Теперь добавим prod-файл для финальной выгрузки на сервер. В prod-варианте наш фронтенд уже собирается в статику и раздаётся при помощи веб-сервера NGINX внутри своего контейнера, поэтому файл nginx.conf необходимо также добавить и в директорию frontend/nginx/, при этом настройка чуть-чуть изменится — у frontend-NGINX появятся proxy_pass на backend и Scalar.
compose.prod.yaml
services:
# Фронтенд сам отдаёт статику через свой NGINX и проксирует /api и /docs
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
target: prod
depends_on:
- backend
- scalar
ports: ["80:80"]
backend:
build:
context: ./backend
dockerfile: Dockerfile
target: prodЗапуск prod-сборки производится той же командой, но с другим override-файлом:
docker compose -f compose.base.yaml -f compose.prod.yaml --env-file .env up -d --buildАрхитектура итогового стенда
В dev-режиме у нас получается такая картина:
flowchart LR
user([Браузер]):::ext --> nginx
subgraph compose[Docker Compose-сеть]
direction LR
nginx["NGINX :80<br/>(единая точка входа)"] -->|"/ + /HMR"| frontend["frontend<br/>Vite dev :5173"]
nginx -->|"/api/*"| backend["backend<br/>Fastify :3000"]
nginx -->|"/docs/*"| scalar["scalar<br/>UI :8080"]
backend --> db[("db<br/>Postgres :5432")]
end
classDef ext fill:#f3f4f6,stroke:#9ca3af,color:#1f2937;
Браузер всегда обращается на localhost:80, NGINX по location-правилам разводит трафик на нужный сервис, а backend ходит в Postgres внутри той же compose-сети по DNS-имени db.
Проверка полученного результата
После того как dev-конфигурация запустилась и миграция применилась, должны открываться:
- Frontend: http://localhost/
- API: http://localhost/api/users
- OpenAPI: http://localhost/openapi.json
- Docs: http://localhost/docs (через NGINX → Scalar)
- Health: http://localhost/api/health (возвращает
{"ok":true})
Если все пять пунктов зелёные, стенд работает.
В следующей лабораторной этот же стенд мы выгрузим в облако: фронтенд — на GitHub Pages, backend — на Render, и наладим CI/CD через GitHub Actions.