Решил описать свой подход построения окружения на Typescript с Nest на бекенде, Nuxt (SPA) на фронтенде. Все заворачивается в один docker‑образ и запускается как standalone приложение c nginx, healthcheck»ами, тестами и ш…широкой сферой применения.

Делал это в качестве фундамента для будущих проектов или с целью изучения Nest, Nuxt 3 с composable функциями. Можно использовать это как инструкцию к настройке подобной архитектуры, можно взять за основу код с github.

Архитектура проекта

Шаблон приложения поставляется в виде одного docker‑образа, в котором установлен nest+nginx и собраны backend и frontend.

Схема архитектуры
Схема архитектуры

Файловая структура

Для начала опишу из как выглядит архитектура проекта.

└── application/
    ├── backend/
    │   └── NEST приложение
    ├── frontend/
    │   └── NUXT приложение
    ├── docker/
    │   └── nginx/
    │       └── conf.conf
    ├── .dockerignore
    ├── .gitignore
    ├── docker-compose.yml
    ├── Dockerfile
    └── readme.md
  • backend — стандартное nest приложение с добавленным serve-static модулем;

  • frontend — стандартное nuxt приложение с добавленным и настроенной связью с backend;

  • docker — папка с конфигами, которые пойдут в docker образ (в текущей версии только nginx);

  • Dockerfile — указания по сборке докер-образа;

  • docker-compose.yml — файл для запуска проекта.

Весь проект доступен на github, его можно склонировать, запустить командой docker-compose up -d (подробнее про запуск написал в конце статьи) и запустить готовый к расширению шаблон приложения. Ниже я описал что именно изменено в стартовых приложениях и каким образом настроена связь между ними

В этом шаблоне нет базы данных и каких‑либо других сторонних зависимостей, чтобы не ограничивать набор компонентов для дальнейшей разработки.

Процесс обработки запросов

В качестве сервера, принимающего запросы используется Nginx. Он раздает статику собранного frontend приложения и перенаправляет запрос на бекенд, если URL запроса начинается на /api

Таким образом может быть 2 типа запроса.

Статический:

И запрос к API:

Подготовка Backend сервиса

За основу взят стартовый набор nest:

$ npm i -g @nestjs/cli
$ nest new backend

Дальше необходимо сделать некоторые доработки. Первым делом в main.ts прописываем порт по умолчанию на 3001, добавляем префикс /api. Таким образом main.ts обретает следующий вид:

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  const port = process.env.APP_PORT || 3001;
  app.setGlobalPrefix('api');
  app.enableCors();
  await app.listen(port);
}
bootstrap();

Настройка static директории

В папку static будет переноситься статичный html/js/css бандл с nuxt приложением и потом раздаваться как статичный сайт при запуске проекта без nginx.

Да, при прочих равных, стоит запускать проект с nginx и для этого не нужно переносить в папку static ничего.

Но на то это и бойлер, что я заранее не знаю как он будет и где запускаться. Может быть, в каких то ситуациях, при малых нагрузках, будет достаточно запуска чистого Nest.

Для того, чтобы nest раздавал статику достаточно подключить модуль serve‑static внутрь AppModule

import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { ServeStaticModule } from '@nestjs/serve-static';
import * as path from 'path';

@Module({
  imports: [
    ServeStaticModule.forRoot({
      rootPath: path.join(__dirname, '..', 'static'),
      serveRoot: '/',
      exclude: ['/api*'],
    }),
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

Обратите внимание на блок exclude: ['/api*']. Это нужно для того, чтобы статика раздавалась на всех ссылках, кроме /api — при запуске проекта по пути /api будет размещаться само nest приложение.

В саму папку static размещаем .gitignore с двумя строчками:

*
!index.html

И index.html, который будет использоваться только при разработке и при сборке конечного docker-образа в эту папку будет складываться html/js/css интерфейса.

Небольшое отступление по поводу префикса к api

В nest можно реализовать префикс /api двумя способами:

  • в каждом контроллере приписывать /api в @Controller('/api/controller-route')

  • прописать на уровне nest приложения глобальный префикс

Я в своем шаблоне использую второй способ. Для его реализации нужно сделать следующее:

  1. Прописать в main.ts строчку app.setGlobalPrefix('api');

  2. Поправить e2e тест, чтобы в нем тоже создавалось приложение с префиксом и поправить сами тесты.

Поскольку я стараюсь разрабатывать через e2e тесты и тестов в проектах может быть очень много, я сразу выношу в отдельную функцию создание тестового приложения:

export async function createTestingApp() {
	return (
	  await Test.createTestingModule({
	    imports: [AppModule],
	  }).compile()
	)
	  .createNestApplication()
	  .setGlobalPrefix('api');
}

Дальше я уже, в тестах, использую эту функцию вместо штатной инициализации приложения:

import { createTestingApp } from './utils/create-testing-app';

// .....

beforeEach(async () => {
  app = await createTestingApp();
  await app.init();
});

Настройка тестового окружения

Я уже немного затронул тестовое окружение в предыдущем блоке, но по тестам я сделал еще небольшие изменения.

  • удалил стандартный spec файл у контроллера, т.к. сам предпочитаю e2e тесты и узкие тесты пишу в редких случаях;

  • поменял формат jest.e2e.config.json на js, тк, зачастую, в проектах приходится добавлять динамические конфигурации и IDE js формат считывает сразу;

  • поправил базовый тест с указанием /api в самих тестах.

Подготовка Frontend

В качестве фронта берется Nuxt 3 и ставится через официальную команду

yarn create nuxt-app frontend

Важный момент: я не буду использовать Nuxt с SSR, т.к. у меня планируется чисто SPA подход (когда браузер загружает целиком весь код к себе и дальше уже рендерит интерфейс).

Да, SSR классно и здорово, но считаю его уместным в проектах с необходимостью поддерживать SEO или если необходимо часть логики отображения скрыть от пользователя (чтобы не показывать какие‑то переменные окружения).

В любом случае, при необходимости, данный стартовый набор можно «переобуть» на работу с SSR. Что бы выключить SSR режим надо в nuxt.config.ts указать ssr: false

Пара слов про Options и Composition

Если вы давно знакомы с Vue, то вы должны знать, что раньше все компоненты можно было делать vue компоненты только через Options подход (создавать объект с полями data, computed и тд). Сейчас появился подход через setup функцию и мне до конца не ясны прелести этого подхода.

Я же остановился, пока что, на подходе через options и постепенно внедряю compose функции в небольших проектах. В текущем наборе я выбрал Composition подход, т.к. тут функционала почти нет и заодно можно попробовать.

Подключение NuxtPage

Изначально в App.vue не проставлен NuxtPage компонент и, следовательно, маршрутизация через файлы в pages работать не будет. Поэтому необходимо App.Vue привести к следующему виду:

<template>
  <div>
    <NuxtPage />
  </div>
</template>

После чего каждый файл в папке pages/ будет открываться по одноименной ссылке в браузере. Подробнее можно прочитать здесь.

Коннектор к API

Для реализации бизнес‑логики во Vue 3 разработчиками можно использовать Composable функции. Раньше я всегда делал подобные вещи в виде отдельного плагина с подстановкой хедера авторизации + указания baseUrl из env переменной.

Сейчас я сделаю по‑современному через создание своей composable функции, расширяющей useFetch. В Nuxt composables создаются автоматически, создав файл в папке composables.

// frontend/composables/api.ts
import { UseFetchOptions } from '#app';
import { NitroFetchRequest } from 'nitropack';
import { KeyOfRes } from 'nuxt/dist/app/composables/asyncData';

export function useApiRequest<T>(
  request: NitroFetchRequest,
  opts?:
    | UseFetchOptions<T extends void ? unknown : T,
    (res: T extends void ? unknown : T) => T extends void ? unknown : T,
    KeyOfRes<(res: T extends void ? unknown : T) => T extends void ? unknown : T>>
    | undefined
) {
  const config = useRuntimeConfig();

  return useFetch(request, {baseURL: config.public.baseURL, ...opts});
}

Чтобы конструкция config.public.baseURL работала, необходимо расширить nuxt.config.ts следующим образом:

export default defineNuxtConfig({
  ssr: false,
  runtimeConfig: {
    public: {
      baseURL: process.env.API_URL || 'http://localhost:3001/',
    },
  },
})

И теперь, по умолчанию, baseURL будет равен http://localhost:3001/, чтобы, при разработке, стучаться в отдельно запущенный Nest. При сборке буду менять его на /api.

Пример использования API вызова

В качестве примера я оставил в компонент, который делает вызов в /api/test и проставляет в разметку все состояния запроса:

<template>
  <div>
    <template v-if="pending">
      Loading
    </template>
    <template v-else>
      <template v-if="data">
        Api result: {{ data }}
      </template>
      <template v-else-if="error">
        Api ERROR: {{ error }}
      </template>
      <button @click="refresh()">refresh</button>
    </template>
  </div>
</template>

<script setup>
import { useApiRequest } from '../composables/api'

const { data, pending, error, refresh } = useApiRequest('/api/test')
</script>

Подготовка Docker-образа и docker-compose.yml

В своем личном блоге я писал и снимал про это видео, что небольшие проекты я разворачиваю достаточно топорным способом:

  • подготовить docker образ;

  • подготовить docker-compose;

  • развернуть на сервере nginx-proxy c acme-companion;

  • запускать проект обычным docker-compose up -d и наслаждаться рабочим продуктом.

Да, это конечно не Kubernetes и не супер отказоустойчивая архитектура. Но такой подход позволяет на VPS на 400р в месяц запустить десяток подобных проектов для личного использования.

Основная идея сборки состоит из следущих этапов:

  1. Собрать frontend (html, js, css).

  2. Собрать backend.

  3. Подсунуть в backend файлы из frontend в папку static.

  4. Собрать nginx образ, который будет разбирать траффик на статику и логику.

Dockerfile с multi-stage build

В проекте я использую node 16 на базе образа alpine. Поэтому начинаем Dockerfile со строчек

FROM node:16-alpine as base-builder
WORKDIR /app

Для начала нужно собрать frontend — подтянуть зависимости, собрать html, js, css.

FROM base-builder as build_fe
WORKDIR /app
COPY ./frontend/package.json ./frontend/yarn.lock* ./
RUN yarn install
ADD ./frontend ./
RUN yarn generate

По итогу в этом промежуточном образе у нас будет собранный frontend в папке /app/dist

Далее собираем backend

FROM base-builder as build_be
WORKDIR /app
COPY ./backend/package.json ./backend/yarn.lock* ./
RUN yarn install
ADD ./backend ./
RUN yarn build

И получаем промежуточный образ только с backend. Теперь осталось собрать воедино в следующий промежуточный образ, который будет на 3001 порту слушать все запросы:

FROM node:16-alpine as finalNode
WORKDIR /app
COPY --from=build_be /app /app
COPY --from=build_fe /app/dist /app/static
CMD yarn start

Я до конца не определился в необходимости этого этапа и, честно говоря, его можно и не делать. У нас, в итоге, получается backend, который умеет также отдавать статику приложения — то есть полностью самостоятельно рабочий docker‑образ с приложением, который может работать без nginx. Но именно в рамках текущей статьи эта возможность не используется

Теперь осталось собрать ту часть, которая будет с nginx:

FROM nginx:alpine as finalNginx
WORKDIR /usr/share/nginx/html
RUN rm -rf ./*
COPY --from=finalNode /app/static .
COPY ./docker/nginx/conf.conf /etc/nginx/conf.d/default.conf
CMD ["nginx", "-g", "daemon off;"]

Также надо не забыть положить файл конфигурации nginx по указанном пути:

# docker/nginx/conf.conf
server {
  listen 80 default_server;
	root /usr/share/nginx/html;

  client_max_body_size 20M;

  location / {
    root   /usr/share/nginx/html;
    index  index.html index.htm;
    try_files $uri $uri/ /index.html;
  }

  location /api {
    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_pass http://node:3001;
  }
}

Теперь мы, в рамках одного Dockerfile получили полную сборку всего, что нужно для работы приложения.

Итоговый Dockerfile можно посмотреть в github репозитории.

Подготовка docker-compose.yml

Как я писал выше, я запускаю подобные проекты на сервере, используя nginx-proxy. Так что, первым делом, в конце файла надо объявить сеть reverse-proxy, через которую будет идти подключение из внешнего мира к моему контейнеру с nginx.

version: "3.8"
services:
# тут будут сервисы
networks:
  reverse-proxy:
    external:
      name: reverse-proxy
  back:
    driver: bridge

Также я добавил сеть back — это изолированная сеть, через которую между собой будут общаться nginx и backend.

Теперь опишем как мы будем запускать наш образ с той частью, которая отвечает за backend:

node:
  build:
    context: .
    target: finalNode
  networks:
    - back
  expose:
    - 3001
  restart: always
  environment:
    - APP_PORT=3001
  healthcheck:
    test: wget --no-verbose --tries=1 --spider <http://localhost:3001> || exit 1
    timeout: 3s
    interval: 3s
    retries: 10

По порядку о каждом параметре:

  • build

    • context — что будет являться текущей директорией при сборке Dockerfile;

    • target — какую часть multi‑stage build нужно запускать в этом месте. В данном случае мы указываем, что собирать нужно все до finalNode.

  • networks

    • тут мы указываем только back, т.к. во внешний мир контейнер ходить не будет и нужен только доступ от nginx к этому контейнеру.

  • expose

    • этот пункт открывает доступ другим контейнерам в сети по перечисленным портам. В данном случае мы сообщаем, что в сети back контейнеры могут подключаться на 3001 порт.

  • restart: always

    • сообщаем, что этот контейнер надо перезапускать всегда. Даже после перезапуска сервера проект будет запущен;

    • будет работать до тех пор пока не выключим его командой docker-compose down.

  • environment

    • передача переменных окружения в сам процесс node;

    • в нашем случае только указываем порт, на котором мы хотим, чтобы backend был запущен.

  • healthcheck

    • прекрасный инструмент для контроля работоспособности контейнера;

    • test — команда, от которой мы ожидаем exit-code = 0 (какие есть еще можно прочитать здесь);

    • timeout — время, которое может выполняться команда. Если команда зависла на больший срок, то проверка считается не пройденой;

    • interval — с какой частотой стоит выполнять команду, чтобы быть уверенным, что контейнер работает;

    • retries — после скольких неудачных ответов сервер помечается «нерабочим».

Теперь добавим блок с запуском nginx:

nginx:
  build:
    context: .
    target: finalNginx
  networks:
    - reverse-proxy
    - back
  expose:
    - 80
  restart: always
  depends_on:
    node:
      condition: service_healthy
  environment:
    - VIRTUAL_HOST=${DOMAIN}
    - VIRTUAL_PORT=80
    - LETSENCRYPT_HOST=${DOMAIN}
    - LETSENCRYPT_EMAIL=test@test.ru

Подробнее:

  • build тоже самое, что в node сервисе, только указан другой target, т.к. нам нужно получить ту часть, которая связана с nginx;

  • networks тут теперь 2 сети:

    • reverse-proxy сеть, через которую будет доступ от контейнера nginx-proxy;

    • back та сеть, в которой есть контейнер node чтобы можно было пересылать запросы ему.

  • expose сообщаем всем в сетях, что в этот контейнер можно стучаться на 80 порт. Это нужно nginx-proxy для обработки запросов;

  • restart аналогично сервису node;

  • depends_on тут мы указываем от каких сервисов мы зависим:

    • если это не указать, то nginx будет запускаться вместе с остальными и может получиться ситуация, в которой node еще не запущен, а nginx уже готов принимать запросы, что нехорошо;

    • поэтому мы указываем, что зависит от node сервиса;

    • зависимость можно считать удовлетворенной только когда сервис прошел свой healthcheck (как раз блок condition).

  • environment

    • тут мы указываем переменные окружения, которые нужны для работы nginx-proxy:

      • VIRTUAL_HOST название домена доступа к приложению;

      • VIRTUAL_PORT порт, на котором запущено приложение в контейнере;

      • LETSENCRYPT_HOST тот же самый домен но уже для создания https сертификата;

      • LETSENCRYPT_EMAIL электронная почта, куда писать о том, что скоро сертификат будет просрочен.

    • тут используется внешняя переменная окружения ${DOMAIN} и она будет записаться из файла .env который будет лежать рядом с docker-compose.yml файлом (подробнее тут).

Конечный вариант файла также находится в github репозитории.

Дополнительные моменты в подготовке окружения

В корне проекта я создал файл .dockerignore чтобы, во время сборки, не перекачивать в контекст лишнего:

#.dockerignore
.idea
.git
**/.nuxt
**/dist
**/.output
**/node_modules
**/.env

Также создал .env.example в качестве файла-примера:

DOMAIN=domain.ru

Запуск приложения

Подготовка сервера

Разумеется на сервере должен уже стоять Docker. Если нет, то установите его по официальной инструкции.

Далее необходимо на сервере запустить nginx-proxy и лучше это делать в отдельном месте на том‑же сервере (инструкция здесь, но если нужно, то напишите в комментариях и дополню эту инструкцию здесь).

Запуск самого приложения

Запускается все это приложение очень простым образом:

  1. Клонируем исходники.

  2. Прописываем DOMAIN в .env файл в корне проекта.

  3. Запускаем командой docker-compose up -d.

Одной командой этот запуск можно сделать следующей командой:

DOMAIN=domain.ru && echo DOMAIN=$DOMAIN > .env && docker-compose up -d --build

Важно: заменить domain.ru на свой домен, который уже направлен на сервер, где мы запускаем сервис.

Обновление версии приложения

Если нужно обновить исходники до последней версии, то можно выполнить следующую команду:

git fetch && git reset --hard origin/master && docker-compose up -d --build

И проект обновится и запустит обновленную версию на домене.

Небольшое заключение

В конечном итоге получился вариант шаблона приложения на Nuxt + Nest который дальше можно расширять. Он крайне пуст — нет БД, авторизации и прочих базовых вещей. Разумеется в наших проектах есть разные шаблоны приложений, но я решил начать с описания самого базового варианта, который дальше можно развивать куда угодно.

Если подобный формат полезен и интересен для дальнейшего описания, то в следующих статьях опишу подобный стартовый набор с базой данных (Postgres) и авторизацией (JWT). Также есть мысль описать процесс подготовки и настройки ansible для подобных проектов.

Также в своем личном блоге в рубрике разработка пишу разные обучающие статьи и делюсь опытом на своем Youtube канале и Telegram.

Благодарю за внимание.

Комментарии (2)


  1. Black_Yuzia
    00.00.0000 00:00
    +1

    Я бы сказал что вместо express стоит использовать fastify. По умолчанию. Все таки по бенчмаркам он продуктивнее.


    1. amorev Автор
      00.00.0000 00:00

      не работал еще с fastify. express достаточно пока было.