Решил описать свой подход построения окружения на 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.mdbackend— стандартное nest приложение с добавленным serve-static модулем.frontend— стандартное nuxt приложение с добавленным и настроенной связью сbackenddocker— папка с конфигами, которые пойдут в 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
По порядку о каждом параметре:
buildcontext— что будет являться текущей директорией при сборкеDockerfiletarget— какую часть 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, т.к. нам нужно получить ту часть, которая связана сnginxnetworksтут теперь 2 сети:reverse-proxyсеть, через которую будет доступ от контейнераnginx-proxybackта сеть, в которой есть контейнерnodeчтобы можно было пересылать запросы ему
exposeсообщаем всем в сетях, что в этот контейнер можно стучаться на 80 порт. Это нужноnginx-proxyдля обработки запросовrestartаналогично сервисуnodedepends_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.
Благодарю за внимание.
