Бэкенд на Fastify
До этого в нашей схеме присутствовал только фронтенд на React. В рамках одного из прошлых заданий мы для него подготовили моки данных, но до сих пор в нашей схеме отсутствовал какой-либо бэкенд - настало время это исправлять.
Настройка среды
В качестве основного фреймворка мы будем использовать Fastify v5. Быстрый бэкенд фреймворк на Node.js. Для начала инициализируем сам проект, создадим рядом с нашем фронтендом папку backend и поместим их в одну общую директорию с названием проекта:
mkdir backend && cd backend
npm init -y
# Fastify v5 + плагины
npm i fastify @fastify/swagger @fastify/helmet @fastify/rate-limit @fastify/cors typebox @fastify/type-provider-typebox
# Типы и TS-инфраструктура
npm i -D typescript ts-node @types/node
npx tsc --init --rootDir src --outDir dist --esModuleInteropКроме самого fastify мы устанавливаем смежные плагины, которые будут использоваться в проекте:
- swagger (генерация OpenAPI) - в рамках прошлой лабораторной с моками, мы видели подобный файл, оформленный по OAS 3.1, который визуаилизировался при помощи Scalar.
- helmet - установка заголовоков (headers) для ответа.
- rate-limit - ограничение количество запросов к API и настройка политик безопасности с целью предотвращения спама запросами (DDOS атаки).
- cors — это механизм, который дает контролировать доступ к тегам на веб странице по сети. Более подробно прочитать про него можете тут. Советую посмотреть, написано достаточно доступно и наглядно.
- typebox - проверка типов с использованием TS.
В качестве базы данных возьмем уже знакомый вам в рамках дисциплины “Программирование на SQL” - Postgres. Но взаимодействовать с ней будем не путем выполнения прямых запросов, как вы это делали дальше, а при помощи ORM.
В нашем проекте будем использовать Prisma ORM - один из самых стабильных и зрелых вариантов в сообществе. Есть и другие варианты, например, Drizzle. Она предалгает более высокую скорость выполнения операций и возможность низкоуровненого формирования запросов, что обеспечивает больший контроль. Но мы пока действуем по принципу наименьшого сопротивления - Prisma обладает более обширным сообществом и не требует углубленной конфигурации при первоначальном запуске.
6.19.0. В версии ^7 изменился принцип загрузки строки подключения к базе, так что чтобы текущее руководство у вас отрабатывало корректно необходимо будет указать в package.json 6 версию призмы и её дочерних модулей, а также не забудьте удалить файл prisma.config.ts, который скорее всего у вас сгенерируется автоматически.npm i prisma @prisma/clientТеперь настроим базовые скрипты для запуска проекта в уже знакомом файле package.json:
{
...
"type": "module",
"scripts": {
"dev": "ts-node src/server.ts",
"build": "tsc",
"start": "node dist/server.js",
"db:generate": "prisma generate",
"db:deploy": "prisma migrate deploy"
}
...
}И сгенерируем файл с базовый схемой данных, который будет описывать структуру нашей будущей БД. Для создания базового шаблона можно воспользоваться командой:
npx prisma init --datasource-provider postgresqlИ после этого не забудем произвести небольшие изменения и настроить автоматически созданный шаблон. Основная строчка, которая нас интересует - output внутри блока generator client. По умолчанию там появляется значение по типу src/generated/..., однако после сборки образа в production код генерируется в директорию dist и все импорты сломаются.
Поэтому сделаем небольшие изменения:
- В качестве генератора устанвим просто
prisma-clientвместоprisma-client-js; - В поле output установим значение, немного отличающееся от стандартного -
../src/generated/prisma.
import { PirsmaClient } 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 курсе в рамках дисцплины “Проектирование баз данных”.
После этого нам необходимо сделать первую миграцию, которая сгенерирует описане структуры БД и перечень всех измнений. Но делать мы это будем только после того как запустим сам контейнер с БД. К этому пункту мы вернемся чуть позже.
Fastify сервер
Теперь создадим базовый файл server.ts, который будет основной для нашего будущего бэкенда приложения и в дальнейшем по мере расширения нашего бэкенда будем делить его на более мелкие модули. Сейчас, пока что, обозначим в нем только основные маршруты для запуска и проверки работоспособности:
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 во всех маршрутах и хукax.
app.decorate('prisma', prisma)
// Хук onClose вызывает $disconnect, когда сервер останавливается, чтобы закрыть соединение с БД.
app.addHook('onClose', async (inst) => {
await inst.prisma.$disconnect()
})
})import type { FastifyError, FastifySchemaValidationError } from 'fastify'
import type { SchemaErrorDataVar } from 'fastify/types/schema.js'
import { Type as T, type Static } from 'typebox'
// Этот модуль собирает переиспользуемые типы и схемы, которые нужны маршрутам Fastify и плагинам.
// Комментарии поясняют не только назначение сущностей, но и связи между Fastify, TypeBox и Prisma.
/**
* Обёртка над стандартной ошибкой Fastify для случаев, когда схема запроса не проходит валидацию.
* Мы расширяем Error, чтобы получить сообщение и stack trace, и одновременно реализуем FastifyError,
* чтобы Fastify понимал код ошибки и корректно возвращал ответ клиенту.
*/
export class ValidationProblem extends Error implements FastifyError {
public readonly name = 'ValidationError'
public readonly code = 'FST_ERR_VALIDATION'
public readonly statusCode = 400
public readonly validation: FastifySchemaValidationError[]
public readonly validationContext: SchemaErrorDataVar
/**
* @param message Сообщение об ошибке, которое увидит клиент.
* @param errs Подробные сведения о том, какие поля не прошли проверку схемы.
* @param ctx Контекст (какая часть запроса проверялась: body, params и т.д.), полезно для логирования.
* @param options Стандартные опции конструктора Error (причина ошибки, управление stack trace и т.д.).
*/
constructor(
message: string,
errs: FastifySchemaValidationError[],
ctx: SchemaErrorDataVar,
options?: ErrorOptions
) {
super(message, options)
this.validation = errs
this.validationContext = ctx
}
}
// Схема ответа в формате RFC 7807 (Problem Details) — единый JSON-формат для сообщений об ошибках.
export const ProblemDetails = T.Object(
{
type: T.String({
description: 'URI с подробным описанием ошибки (по умолчанию about:blank)'
}),
title: T.String({
description: 'Короткое человекочитаемое резюме проблемы'
}),
status: T.Integer({
minimum: 100,
maximum: 599,
description: 'HTTP-статус, с которым был отправлен ответ'
}),
detail: T.Optional(
T.String({
description: 'Дополнительные сведения о том, что пошло не так'
})
),
instance: T.Optional(
T.String({
description: 'URI запроса, в котором возникла проблема (если полезно для клиента)'
})
),
// Поле errorsText даёт краткое текстовое представление всех ошибок валидации, если они есть.
errorsText: T.Optional(
T.String({
description: 'Сводное описание всех ошибок, собранных валидацией Fastify'
})
)
},
{ additionalProperties: true }
)
export type ProblemDetails = Static<typeof ProblemDetails>
// Схема и тип пользователя, которые используются и в валидаторах, и в ответах API.
export const User = T.Object({
id: T.String({ description: 'Уникальный идентификатор пользователя (UUID или аналогичный формат)' }),
email: T.String({
format: 'email',
description: 'Адрес электронной почты, используется как логин и для отправки уведомлений'
})
})
export type User = Static<typeof User>
// Минимальная схема для health-check запроса: позволяет внешним сервисам понять, что backend жив.
export const Health = T.Object({
ok: T.Boolean({
description: 'Флаг готовности сервиса: true означает, что Fastify и его зависимости работают'
})
})
export type Health = Static<typeof Health>import Fastify, { type FastifyError } from 'fastify'
import helmet from '@fastify/helmet'
import cors from '@fastify/cors'
import rateLimit from '@fastify/rate-limit'
import swagger from '@fastify/swagger'
import { STATUS_CODES } from 'node:http'
import prismaPlugin from './plugins/prisma.js'
import { Type as T } from 'typebox'
import type { TypeBoxTypeProvider } from '@fastify/type-provider-typebox'
import { ValidationProblem, ProblemDetails, User, Health } from './types.js'
// Этот модуль собирает все настройки Fastify: плагины инфраструктуры, обработчики ошибок и маршруты API.
/**
* Создает и настраивает экземпляр Fastify, готовый к запуску.
*/
export async function buildApp() {
const app = Fastify({
logger: true, // Подключаем встроенный логгер Fastify.
trustProxy: true, // Разрешаем доверять заголовкам X-Forwarded-* от прокси/ingress.
/**
* Схема валидации TypeBox -> Fastify генерирует массив ошибок.
* Мы превращаем его в ValidationProblem, чтобы вернуть клиенту единый формат Problem Details.
*/
schemaErrorFormatter(errors, dataVar) {
const msg = errors.map((e) => e.message).filter(Boolean).join('; ') || 'Validation failed'
return new ValidationProblem(msg, errors, dataVar)
}
}).withTypeProvider<TypeBoxTypeProvider>() // Позволяет Fastify понимать типы TypeBox при описании схем.
// === Инфраструктурные плагины ===
// Helmet добавляет безопасные HTTP-заголовки (Content-Security-Policy, X-DNS-Prefetch-Control и др.).
await app.register(helmet)
// CORS ограничивает кросс-доменные запросы. Здесь полностью запрещаем их (origin: false) по умолчанию.
await app.register(cors, { origin: false })
/**
* Ограничитель количества запросов на IP.
* Плагин автоматически вернет 429, а мы формируем Problem Details в errorResponseBuilder.
*/
await app.register(rateLimit, {
max: 100, // Максимум 100 запросов
timeWindow: '1 minute', // За одну минуту
enableDraftSpec: true, // Добавляет стандартные RateLimit-* заголовки в ответ
addHeaders: {
'x-ratelimit-limit': true,
'x-ratelimit-remaining': true,
'x-ratelimit-reset': true,
'retry-after': true
},
errorResponseBuilder(request, ctx) {
const seconds = Math.ceil(ctx.ttl / 1000)
return {
type: 'about:blank',
title: 'Too Many Requests',
status: 429,
detail: `Rate limit exceeded. Retry in ${seconds} seconds.`,
instance: request.url
} satisfies ProblemDetails
}
})
/**
* Документация API в формате OpenAPI 3.0.
*/
await app.register(swagger, {
openapi: {
openapi: '3.0.3',
info: {
title: 'Rooms API',
version: '1.0.0',
description: 'HTTP-API, совместим с RFC 9457.'
},
servers: [{ url: 'http://localhost:3000' }],
tags: [
{ name: 'Users', description: 'Маршруты для управления пользователями' },
{ name: 'System', description: 'Служебные эндпоинты' }
]
}
})
// Плагин с PrismaClient: открывает соединение с БД и добавляет app.prisma во все маршруты.
await app.register(prismaPlugin)
// === Глобальные обработчики ошибок ===
/**
* Единая точка обработки ошибок. Мы приводим их к Problem Details и отправляем клиенту JSON.
* ValidationProblem превращается в 400, остальные ошибки хранят свой статус или получают 500.
*/
app.setErrorHandler<FastifyError | ValidationProblem>((err, req, reply) => {
const status = typeof err.statusCode === 'number' ? err.statusCode : 500
const isValidation = err instanceof ValidationProblem
const problem = {
type: 'about:blank',
title: STATUS_CODES[status] ?? 'Error',
status,
detail: err.message || 'Unexpected error',
instance: req.url,
...(isValidation ? { errorsText: err.message } : {})
}
reply.code(status).type('application/problem+json').send(problem)
})
// Отдельный обработчик 404: отвечает в формате Problem Details.
app.setNotFoundHandler((request, reply) => {
reply.code(404).type('application/problem+json').send({
type: 'about:blank',
title: 'Not Found',
status: 404,
detail: `Route ${request.method} ${request.url} not found`,
instance: request.url
} satisfies ProblemDetails)
})
// === Маршруты API ===
/**
* GET /api/users — примеры чтения данных из базы через Prisma.
*/
app.get(
'/api/users',
{
schema: {
operationId: 'listUsers',
tags: ['Users'],
summary: 'Возвращает список пользователей',
description: 'Получаем id и email для каждого пользователя.',
response: {
200: {
description: 'Список пользователей',
content: { 'application/json': { schema: T.Array(User) } }
},
429: {
description: 'Too Many Requests',
headers: {
'retry-after': {
schema: T.Integer({ minimum: 0, description: 'Через сколько секунд можно повторить запрос' })
}
},
content: { 'application/problem+json': { schema: ProblemDetails } }
},
500: {
description: 'Internal Server Error',
content: { 'application/problem+json': { schema: ProblemDetails } }
}
}
}
},
async (_req, _reply) => {
// Prisma автоматически превращает результат в Promise; Fastify вернет массив как JSON.
return app.prisma.user.findMany({ select: { id: true, email: true } })
}
)
/**
* GET /api/health — health-check для мониторинга.
* Пытаемся сделать минимальный запрос в БД. Если БД недоступна, возвращаем 503.
*/
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 } }
},
429: {
description: 'Too Many Requests',
headers: {
'retry-after': { schema: T.Integer({ minimum: 0 }) }
},
content: { 'application/problem+json': { schema: ProblemDetails } }
},
500: {
description: 'Internal Server Error',
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)
}
}
)
// Служебный маршрут: возвращает OpenAPI-спецификацию.
app.get(
'/openapi.json',
{
schema: { hide: true, tags: ['Internal'] } // Скрыт из списка, но доступен для клиентов/тестов
},
async (_req, reply) => {
reply.type('application/json').send(app.swagger())
}
)
return app
}import { inspect } from 'node:util'
type BuildApp = typeof import('./app.js').buildApp
type AppInstance = Awaited<ReturnType<BuildApp>>
function describeError(err: unknown) {
if (err instanceof Error) {
const meta: Record<string, unknown> = {}
const nodeErr = err as NodeJS.ErrnoException & { cause?: unknown }
if (nodeErr.code !== undefined) meta.code = nodeErr.code
if (nodeErr.errno !== undefined) meta.errno = nodeErr.errno
if (nodeErr.syscall !== undefined) meta.syscall = nodeErr.syscall
if (nodeErr.path !== undefined) meta.path = nodeErr.path
if (nodeErr.address !== undefined) meta.address = nodeErr.address
if (nodeErr.port !== undefined) meta.port = nodeErr.port
if (nodeErr.cause !== undefined) meta.cause = nodeErr.cause
if (err instanceof AggregateError) {
meta.errors = err.errors.map((item) =>
item instanceof Error ? { name: item.name, message: item.message, stack: item.stack } : item
)
}
return { error: err, meta: Object.keys(meta).length ? meta : undefined }
}
const preview = inspect(err, { depth: 6 })
return {
error: new Error(`Non-Error thrown: ${preview}`),
meta: { thrownType: typeof err, thrownPreview: preview }
}
}
async function main() {
let app: AppInstance | undefined
try {
const { buildApp } = await import('./app.js')
// Создаем и настраиваем экземпляр Fastify, подключая плагины и маршруты из app.ts.
app = await buildApp()
// Определяем параметры запуска HTTP-сервера. Переменные окружения позволяют менять их без перекомпиляции.
const port = Number(process.env.PORT ?? 3000)
const host = process.env.HOST ?? '0.0.0.0'
let closing = false
// Функция корректного завершения работы приложения при получении сигнала ОС (Ctrl+C или остановка контейнера).
const shutdown = async (reason: string, err?: unknown) => {
if (closing) return
closing = true
if (app) {
if (err) {
const { error, meta } = describeError(err)
app.log.fatal({ err: error, meta }, `fatal: ${reason}`)
}
else app.log.info({ reason }, 'Shutting down...')
try {
// Fastify аккуратно завершает все активные запросы и вызывает onClose-хуки (например, отключает Prisma).
await app.close()
} finally {
process.exit(err ? 1 : 0)
}
} else {
if (err) {
const { error, meta } = describeError(err)
console.error(`fatal before app init: ${reason}`, error)
if (meta) console.error('fatal details:', meta)
} else {
console.error(`fatal before app init: ${reason}`)
}
process.exit(1)
}
}
// Подписываемся на стандартные сигналы завершения процесса и вызываем graceful shutdown.
process.once('SIGINT', () => void shutdown('SIGINT'))
process.once('SIGTERM', () => void shutdown('SIGTERM'))
process.once('unhandledRejection', (reason) => void shutdown('unhandledRejection', reason))
process.once('uncaughtException', (error) => void shutdown('uncaughtException', error))
// Запускаем HTTP-сервер. Fastify сам обработает входящие запросы и будет логировать события.
await app.listen({ port, host })
} catch (err) {
const { error, meta } = describeError(err)
console.error('Failed to start application:', error)
if (meta) console.error('Error details:', meta)
process.exit(1)
}
}
void main()openapi.json) будет генерироваться автматически на основании комментариев к методам, которым мы оставляем в коде. За это отвечает модуль Swagger.Dockerfile для бэкенда
Когда базовые файлы для нашего бэкенд проекта готовы, можешь написать для него Dockerfile, который позволит разместить нам его при запуске рядом со фронтендом. Вгылядеть он будет следуюшим образом:
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; node --loader ts-node/esm 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 призваны сгенерировать типы, которыми мы операруем как объектами в коде с соответствии со схемой БД и применить ранее созданные миграции.
Запуск всего проекта и настройка docker-compose
После всех эти манипуляцией с бэкендом части, общая структура нашего проекта должна выглядеть примерно следующим образом (после завершения этого модуля):
-
- schema.prisma
-
- server.ts
- …
- Dockerfile
- …
-
- nginx.conf
-
- …
- Dockerfile
- .dockerignore
- vite.config.ts
- …
- nginx.conf
При создании единого, “базового” compose файла, будем основываться на концепции override’ов, которая кратко уже рассматривалась в секции с моками.
У нас с вам будет базовый файл compose.base.yaml и его вариации - dev и prod, которые будут частично менять конфгурацию или параметры несколькоих сервисов в зависимости от того в каком окружении мы их с вами запускаем. Начнем с того, что инициализируем базовый файл:
# БАЗОВЫЕ ЯКОРЯ
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:
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. ENV (Enviromental Variables) - это переменные среды. С их помощью можно настраивать конфигурацию окружения, и программы в частности. Файлы .env обычно добавляются в исключения .gitignore.
.env.example, в котором содержится описание и пример всех конфигурационных переменных, которые поддерживает проект. А в системах автоматической сборки и тестирования (CI) они задаются при помощи специальных инструментов платформы, например, - Github Secrets.Для нашего проекта стандартный .env файл может выглядеть примерно следующим образом:
POSTGRES_DB=example
POSTGRES_USER=exampleuser
POSTGRES_PASSWORD=examplepassword.env.dev, .env.prod и др. Их можно также перезназначать в зависимости от того с каким файлом мы работаем - compose.dev.yml, …Теперь, когда переменные среды с конфигурированы, добавим нашу dev вариацию:
services:
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
target: dev
expose: ["5173"]
volumes:
- ./frontend:/app
- /app/node_modules
environment:
- WATCHPACK_POLLING=true
nginx:
image: nginx: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-потоки
}
}Для того, чтобы запустить проект достаточно ввести: docker compose -f compose.base.yml -f compose.dev.yml --env-file .env up -d --build:
docker compose- запуск самого compose;-f compose.base.yml- наш файл с базовой конфигурацией;-f compose.dev.yml- файл, который перезаписывает / расширяет базовую (прошлую) конфигурацию;--env-file .env- путь и имя нашего файла с переменными среды;up -d --build- сборка контейнеров и тихий запуск контейнера.
TLS..., включите VPN и повторите попытку сборки (--build) заново. В последнее время достаточно часто наблюдаются сбои при попытке обратиться к серверам Prisma с российского IP.После первого запуска не забываем приминить первую миграцию, иначе наш бэкенд нормалньо не запустится. Для этого заходим вкладку exec внутри контейнера backend:
Или вводим в командную строку на хост машине команду чтобы зайти в bash внутри конкретного контейнера: docker exec -it backend /bin/bash (запускать необходимо из той директории, где у нас находятся compose файлы).
Теперь можем применить саму миграцию путем ввода и запуска команды:
npx prisma migrate dev --name init --schema=prisma/schema.prismacompose.dev.yml, который мы с вами написали ранее, то вы заметите там такую строчку в секции volumes у backend: - ./backend/prisma:/app/prisma. Благодаря ней наша папка prisma монтируется внутрь контейнера и все миграции, которые были созданы там, - появятся в этой папке и внутри нашей основной хост системы.Настройка prod-конфигурации
А теперь можно добавить и prod-файл для сборки проекта и финальной выгрузки на сервер. В prod варианте наш фронтенд уже собирается в статику и раздается при помощи веб-сервера NGINX, поэтому файл nginx.conf необходимо также будет добавить и в директорию с ним, при этом настройка чуть-чуть изменится:
compose.prod.yml
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: prodDockerfile
FROM node:24-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci
FROM node:24-alpine AS dev
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
# Копируем остальной код
COPY . .
# Открываем порт dev-сервера
EXPOSE 5173
# Запускаем Vite
CMD ["npm","run","dev"]
FROM node:24-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM nginx:1.29-alpine AS prod
# Замена стандартного конфига NGINX
RUN rm -f /etc/nginx/conf.d/default.conf
COPY ./nginx/nginx.conf /etc/nginx/conf.d/app.conf
# Копируем результаты сборки
COPY --from=build /app/dist/ /usr/share/nginx/html/
EXPOSE 80NGINX
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;
# --- корень статики (Vite build)
root /usr/share/nginx/html;
index index.html;
# --- Docker DNS
# Иначе при смене IP у контейнера может потребоваться рестарт NGINX
resolver 127.0.0.11 ipv6=off valid=10s; # встроенный DNS Docker
resolver_timeout 5s;
set $backend http://backend:3000;
set $scalar http://scalar:8080;
# ===== Проксирование API и Scalar =====
location ^~ /api/ {
proxy_pass $backend;
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 120s;
}
location = /openapi.json { proxy_pass $backend; }
# Подключаем Scalar под /docs/
location ^~ /docs/ {
rewrite ^/docs/(.*)$ /$1 break;
proxy_pass $scalar;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_read_timeout 120s;
}
# ===== Кэширование статики =====
location = /index.html {
expires -1;
}
location ~* \.(?:js|css|svg|ico|png|jpg|jpeg|gif|webp|avif|woff2?)$ {
expires 1y;
add_header Vary "Accept-Encoding";
try_files $uri =404;
}
# ===== SPA fallback =====
# Все неизвестные пути — на index.html
location / {
try_files $uri $uri/ /index.html;
}
}Проверка полученного результата
- Frontend: http://localhost/
- API: http://localhost/api/users
- OpenAPI: http://localhost/openapi.json
- Docs: http://localhost/docs (через Nginx→Scalar)
- Health: http://localhost/api/health