Решил описать свой подход построения окружения на 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 приложения глобальный префикс
Я в своем шаблоне использую второй способ. Для его реализации нужно сделать следующее:
-
Прописать в
main.tsстрочкуapp.setGlobalPrefix('api'); -
Поправить 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р в месяц запустить десяток подобных проектов для личного использования.
Основная идея сборки состоит из следущих этапов:
-
Собрать
frontend(html, js, css). -
Собрать
backend. -
Подсунуть в
backendфайлы из frontend в папкуstatic. -
Собрать 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 и лучше это делать в отдельном месте на том‑же сервере (инструкция здесь, но если нужно, то напишите в комментариях и дополню эту инструкцию здесь).
Запуск самого приложения
Запускается все это приложение очень простым образом:
-
Клонируем исходники.
-
Прописываем
DOMAINв.envфайл в корне проекта. -
Запускаем командой
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.
Благодарю за внимание.
ссылка на оригинал статьи https://habr.com/ru/post/720000/
Добавить комментарий