Бэкенд на Fastify

Бэкенд на 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.

ORM (Object-Relational Mapping, объектно-реляционное отображение) — технология программирования, суть которой заключается в создании «виртуальной объектной базы данных». Фактически вы начинаете взаимодействовать с базой путем совершения операций с объектами.

В нашем проекте будем использовать Prisma ORM - один из самых стабильных и зрелых вариантов в сообществе. Есть и другие варианты, например, Drizzle . Она предалгает более высокую скорость выполнения операций и возможность низкоуровненого формирования запросов, что обеспечивает больший контроль. Но мы пока действуем по принципу наименьшого сопротивления - Prisma обладает более обширным сообществом и не требует углубленной конфигурации при первоначальном запуске.

Пожалуйста, обратите внимание, что руководство писалось для версии призмы 6.19.0. В версии ^7 изменился принцип загрузки строки подключения к базе, так что чтобы текущее руководство у вас отрабатывало корректно необходимо будет указать в package.json 6 версию призмы и её дочерних модулей, а также не забудьте удалить файл prisma.config.ts, который скорее всего у вас сгенерируется автоматически.
npm i prisma @prisma/client

Теперь настроим базовые скрипты для запуска проекта в уже знакомом файле package.json:

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.
Раньше можно было бы убрать просто убрать строку output в принципе и продолжил бы работать стандартный import { PirsmaClient } from '@prisma/client', но начиная с v7 в Prisma она станет обязательной , поэтому лучше подготовиться к этому заранее.
prisma/schema.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, который будет основной для нашего будущего бэкенда приложения и в дальнейшем по мере расширения нашего бэкенда будем делить его на более мелкие модули. Сейчас, пока что, обозначим в нем только основные маршруты для запуска и проверки работоспособности:

src/plugins/prisma.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()
  })
})
src/types.ts
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>
src/app.ts
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
}
src/server.ts
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()
Если помните, то в прошлых лабораторных работах мы с вами смотрели на примерный вариант API спецификации, который у нас должен получиться. Теперь сама эта спецификация (файл OAS 3.1 - openapi.json) будет генерироваться автматически на основании комментариев к методам, которым мы оставляем в коде. За это отвечает модуль Swagger .

Dockerfile для бэкенда

Когда базовые файлы для нашего бэкенд проекта готовы, можешь написать для него Dockerfile, который позволит разместить нам его при запуске рядом со фронтендом. Вгылядеть он будет следуюшим образом:

backend/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
  • .env
  • compose.base.yaml
  • compose.dev.yaml
  • compose.prod.yaml
  • При создании единого, “базового” compose файла, будем основываться на концепции override’ов, которая кратко уже рассматривалась в секции с моками.

    У нас с вам будет базовый файл compose.base.yaml и его вариации - dev и prod, которые будут частично менять конфгурацию или параметры несколькоих сервисов в зависимости от того в каком окружении мы их с вами запускаем. Начнем с того, что инициализируем базовый файл:

    compose.base.yaml
    # БАЗОВЫЕ ЯКОРЯ
    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
    Файлов с переменным среды (как и с compose конфигурациями) - может быть несколько. Например .env.dev, .env.prod и др. Их можно также перезназначать в зависимости от того с каким файлом мы работаем - compose.dev.yml, …

    Теперь, когда переменные среды с конфигурированы, добавим нашу dev вариацию:

    compose.dev.yaml
    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, заранее созданной в корне:

    nginx/nginx.conf
    # --- 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 - сборка контейнеров и тихий запуск контейнера.
    Если при сборке контейнер с backend будет постоянно падать с ошибками TLS..., включите VPN и повторите попытку сборки (--build) заново. В последнее время достаточно часто наблюдаются сбои при попытке обратиться к серверам Prisma с российского IP.

    После первого запуска не забываем приминить первую миграцию, иначе наш бэкенд нормалньо не запустится. Для этого заходим вкладку exec внутри контейнера backend: Docker Desktop - Exec Или вводим в командную строку на хост машине команду чтобы зайти в bash внутри конкретного контейнера: docker exec -it backend /bin/bash (запускать необходимо из той директории, где у нас находятся compose файлы).

    Теперь можем применить саму миграцию путем ввода и запуска команды:

    npx prisma migrate dev --name init --schema=prisma/schema.prisma
    Если вы внимательно посмотрите на compose.dev.yml, который мы с вами написали ранее, то вы заметите там такую строчку в секции volumes у backend: - ./backend/prisma:/app/prisma. Благодаря ней наша папка prisma монтируется внутрь контейнера и все миграции, которые были созданы там, - появятся в этой папке и внутри нашей основной хост системы.

    Настройка prod-конфигурации

    А теперь можно добавить и prod-файл для сборки проекта и финальной выгрузки на сервер. В prod варианте наш фронтенд уже собирается в статику и раздается при помощи веб-сервера NGINX, поэтому файл nginx.conf необходимо также будет добавить и в директорию с ним, при этом настройка чуть-чуть изменится:

    compose.prod.yml

    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

    Dockerfile

    frontend/Dockerfile
    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 80

    NGINX

    frontend/nginx/nginx.conf
    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;
      }
    }

    Проверка полученного результата