Меня зовут Стас. У меня нет опыта в коммерческой разработке — ни фронтенд, ни бэкенд, ни devops. Есть MacBook Air M2, 16 ГБ и привычка докапываться до сути.

В декабре 2025 года я приехал к родителям на новый год и обнаружил, что интернета нет. Не медленный — совсем. РКН тестировал белые списки, и мой регион попал в первую волну. ChatGPT, Claude, YouTube, Telegram — всё за стеной. OpenVPN, которым я пользовался раньше, тоже заблокировали — мой университет до сих пор сосёт лапу, потому что подключал студентов через него.

Это статья о том, как из этой точки я пришёл к работающему SaaS с бэкендом на FastAPI, фронтом на React, PostgreSQL, системой деплоя на несколько VPS и AI-assisted процессом разработки, который я выстраивал итеративно.


Hysteria2: с чего всё началось

Решение нашёл быстро — протокол Hysteria2. Работает поверх QUIC (UDP), маскируется под обычный HTTPS-трафик, практически не детектируется DPI. Развернул VPS, поднял контейнер — и снова в современном мире.

Но пока читал документацию, заметил кое-что интересное: Hysteria2 упакован в Docker-контейнер и предоставляет REST API. Через него можно в реальном времени получать список подключённых пользователей, управлять трафиком, добавлять и удалять клиентов. Это был API, который можно дёргать.

Сначала курлами. Потом пришла мысль автоматизировать через Python — так познакомился с библиотекой requests. Накидал скрипт с хардкоженным словарём пользователей и функцией опроса /online. Код был простым, но факт того, что я могу программно управлять VPN-сервером — зацепил. Захотелось сделать из этого что-то настоящее.


Архитектура: три независимых контейнера

С самого начала разделил проект на три изолированные части:

htrBox/
├── backend/      — FastAPI, PostgreSQL, бизнес-логика
├── frontend/     — React + Vite, пользовательский интерфейс  
├── hysteria/     — Hysteria2, принимает VPN-соединения
└── docker-compose.yaml

Каждая часть — отдельный Docker-контейнер. Зоны ответственности не пересекаются: бэкенд общается с Hysteria2 по REST, фронт общается с бэкендом по REST, Hysteria2 ни о чём кроме VPN не знает.

docker-compose.yaml поднимает весь стек одной командой. Hysteria2 тянется с Docker Hub как готовый образ. Бэкенд и фронт собираются локально из Dockerfile.

При написании Dockerfile важный момент — порядок слоёв. Слои кэшируются Docker'ом снизу вверх: если COPY requirements.txt стоит раньше COPY source/, то при изменении кода зависимости не пересобираются. На медленном CI это экономит минуты.

Для продакшна рядом лежит prod/infra/docker-compose.yaml с отдельной конфигурацией под каждый VPS-узел:

prod/servers/
├── docker-compose.ge.yaml   — Грузия
├── docker-compose.nl.yaml   — Нидерланды
├── docker-compose.pl.yaml   — Польша
└── docker-compose.se.yaml   — Швеция

Hysteria2-ноды принимают соединения от пользователей и проксируют трафик дальше. Бэкенд живёт в Yandex Cloud отдельно.


Бэкенд: FastAPI + PostgreSQL

Структура бэкенда сложилась итеративно:

backend/source/
├── main.py
├── auth_jwt.py
├── config.py
├── database.py
├── schemas.py
├── rate_limiter.py
├── maintenance.py
├── traffic_collector.py
├── routers/
│   ├── auth.py
│   ├── users.py
│   ├── hysteria.py
│   ├── servers.py
│   ├── traffic.py
│   └── ws.py          ← WebSocket

Авторизация — JWT с двумя токенами: access (короткоживущий) и refresh. Refresh-токен хранится в httpOnly cookie— проставляется через заголовок в ответе бэкенда. Это не случайное решение: httpOnly означает, что JavaScript на странице вообще не имеет доступа к этому куки. XSS-атака не сможет украсть токен, потому что document.cookie его не видит. В localStorage refresh-токен класть принципиально нельзя — это первое, что проверяет любой XSS.

В структуре есть routers/ws.py и useWebSocket.ts на фронте — WebSocket-инфраструктура написана, но код закомментирован. На текущем масштабе поллинг через TanStack Query справляется с задачей, и усложнять без необходимости не хотелось. Задел на будущее — когда появятся сценарии, где задержка поллинга будет ощутима.

С базой данных столкнулся с проблемой, которую поначалу не замечал: ИИ выстроил не все связи между таблицами, поэтому при удалении пользователя оставались висячие записи в связанных таблицах. Разобрался с ON DELETE CASCADE — внешний ключ с каскадным удалением гарантирует, что зависимые строки удаляются автоматически. После этого прошёл курс Postgres Pro и их книгу по SQL — стало понятно как проектировать схему, что такое нормализация, как писать DDL.

Для тестирования API использую Postman. Классная фича — наследование авторизации коллекции: задаёшь токен один раз на уровне коллекции, все запросы его подхватывают. Три часа потратил на то, что Postman не видел httpOnly cookie — оказалось, нужно выставить тумблер Cookie Jar в OFF для каждого эндпоинта отдельно.


AI-assisted разработка: рабочий процесс

Здесь хочу подробно остановиться, потому что просто «писать с ИИ» и «иметь воспроизводимый процесс с ИИ» — разные вещи.

Проблема контекста

ИИ не удерживает контекст между сессиями. Каждый раз объяснять структуру проекта, эндпоинты, .env, связи между частями — боль и трата токенов. Решение: скармливать архив проекта напрямую в чат. Claude и Manus умеют работать с tar-архивами.

pack.sh: упаковка контекста

Написал bash-скрипт с тремя режимами:

pack          # весь проект (кроме .git, venv, node_modules, dist)
pack back     # только backend + docker-compose.yaml + .env
pack front    # только frontend + docker-compose.yaml + .env
pack --dry-run [all|back|front]  # показать команду без выполнения

Три режима нужны для экономии токенов: если правлю роутер на бэкенде, фронт с его 50+ компонентами не нужен в контексте. Режим --dry-run — перед тем как скинуть архив в чат, убеждаюсь что в него не попало лишнее. Флаг -y убирает подтверждение перезаписи, чтобы не прерывать автоматизацию.

Исключения при полной упаковке:

declare -a EXCLUDES=(
    ".git"
    ".env"
    "backend/venv"
    "frontend/node_modules"
    "frontend/package-lock.json"
    "frontend/dist"
    ".DS_Store"
)

TODO-подход к разработке фич

Раньше сразу говорил ИИ: «давай реализуем фичу». Теперь — нет. Сначала:

Не трогай код. Опиши план внедрения фичи с TODO-маркерами и подробным описанием каждого шага — так, чтобы человек, который вообще не в теме проекта, понял что делать.

Обкашливаем план. Только потом: «вперёд, с первого пункта».

ИИ отдаёт только изменённые файлы, включая обновлённый TODO.md с прогрессом после каждого шага. Смотрю git diff в VSCode — если всё ок, двигаемся дальше.

Кайф такого подхода: когда ИИ галлюцинирует или заканчиваются токены на аккаунте — не страшно. Делаю ./pack.sh, перекидываю архив с актуальным TODO.md на новый аккаунт и продолжаю с того места где остановился. Полный контроль над процессом.


Дизайн-система для ИИ

Это самое нетривиальное решение во всём проекте — и возникло из конкретной боли.

Проблема: когда просишь ИИ написать новую страницу или компонент, он расставляет Tailwind-классы случайно. На одной странице text-sm, на другой text-xs, на третьей text-base. Где-то gap-2, где-то gap-3. Цвета, отступы, радиусы — всё вразнобой. Проект начинает выглядеть как лоскутное одеяло.

Решение: создать один файл с правилами, которому ИИ следует при каждом запросе. PROMT.md во фронтенде — это 250+ строк инструкций, которые описывают дизайн-систему и алгоритм работы с ней.

Структура системы:

src/styles/
├── tokens.ts        — атомарные токены: typography, surface, radius, spacing
├── animations.ts    — transition, hover, press, enter, loading
├── variants.ts      — colorScheme + тип ColorScheme
├── index.ts         — единая точка входа, реэкспортирует всё
└── cStyles/         — стили компонентов, разбитые по папкам
    ├── uiStls.ts
    ├── commonStls.ts
    ├── layoutStls.ts
    ├── dashboardStls.ts
    ├── usersStls.ts
    ├── serversStls.ts
    └── pagesStls.ts

Правило маппинга жёсткое: компонент из components/ui/ — стили только в cStyles/uiStls.ts. Компонент из pages/ — только в cStyles/pagesStls.ts. ИИ не может положить стили куда попало.

Использование в JSX после миграции выглядит так:

import { styles, colorScheme } from "@/styles";
import type { ColorScheme } from "@/styles";

const s = styles.userRow;
const v = colorScheme[variant];

<div className={cn(s.root, v.bg, v.border)}>
  <p className={cn(s.title, v.text)}>{title}</p>
</div>

Ноль сырых Tailwind-классов в JSX после миграции. Все цвета — через colorScheme, все отступы и типографика — через токены. Когда теперь прошу ИИ написать новый компонент, он читает PROMT.md и работает в системе координат дизайн-системы, а не изобретает классы заново.

Цветовые варианты — через ColorScheme, а не через локальный variantStyles в компоненте:

// ❌ так нельзя — локальный вариант
const variantStyles = {
  danger: "bg-red-500 text-white",
  warning: "bg-amber-500 text-white",
};

// ✅ так правильно — через систему
const v = colorScheme[variant]; // variant: ColorScheme
<div className={cn(s.root, v.bg, v.border, v.text)} />

Фронтенд: стек и решения

После нескольких попыток с Manus (ИИ-агент, пишет веб-приложения с нуля) выбросил весь сгенерированный код — там много мусора: отладочные скрипты, вспомогательные файлы, непоследовательная структура. Начал с нуля с Claude, с полным пониманием каждого шага.

Сборка: Vite. В режиме разработки выступает сервером — по запросу браузера отдаёт собранную страницу. Поддерживает TypeScript и TSX из коробки.

Состояние и данные:

  • TanStack Query — автоматическое обновление данных, кэширование, инвалидация. REST-эндпоинты опрашиваются с нужным интервалом, UI перерисовывается при изменениях.

  • Zustand + localStorage — кэширование состояния между сессиями. После перезагрузки страницы не нужен повторный вход.

  • wouter — лёгкий роутер. Значительно проще React Router, без лишних абстракций.

  • httpOnly cookie — refresh-токен, проставляется бэкендом.

Эксперимент с WebGL: В src/shaders/ лежат background.frag.glsl и background.vert.glsl — следы одного эксперимента. Мне понравился анимированный фон на странице Яндекс.Музыки, и я решил разобраться как он устроен. Открыл DevTools, нашёл в исходниках GLSL-шейдеры, скопировал и адаптировал под свой проект. Пришлось разобраться как работают uniform-переменные, как передавать время через requestAnimationFrame, как подключить .glsl-файлы через Vite (нужен отдельный плагин, декларация типа в glsl.d.ts).

Выглядело красиво. Но на слабых устройствах фрагментный шейдер заметно грузил GPU — страница начинала тормозить. Решение оказалось простым: убрать. Файлы остались как память об эксперименте, но в продакшне шейдер не используется. Иногда правильное инженерное решение — это отказаться от того, что работает, но не для всех.

Перед тем как внедрять TanStack Query и Zustand в основной проект, сделал отдельный мини-проект — упрощённую копию без бизнес-логики. Разобрался там как работает поллинг, как Zustand синхронизируется с localStorage, как JWT refresh flow устроен изнутри. Только потом перенёс в основной.


Деплой: bash вместо Ansible

Весь деплой — bash-скрипт prod/deploy.sh. Накатывает Hysteria2 на ноды в четырёх странах и бэкенд в Yandex Cloud. Пример функции деплоя на YC с автоматическим продлением сертификатов через certbot:

deploy_yandex() {
  local host="$YC_HOST"
  
  ssh "$host" "
    cd /opt/htrbox &&
    git pull &&
    docker compose pull &&
    docker compose up -d --build
  "
  
  # Продление сертификата если истекает в течение 30 дней
  ssh "$host" "certbot renew --quiet --deploy-hook 'nginx -s reload'"
}

Пытался переписать на Ansible Playbooks — повяз. Для пяти серверов bash справляется отлично, а риск положить пользователям интернет кривым playbook'ом не стоил эксперимента. Это сознательное решение, а не откладывание.

Рядом лежит prod/cleanup.sh — убирает старые образы, контейнеры, сети на нодах.

GitKraken для визуализации веток — значительно удобнее терминального tig. Для одного разработчика бесплатный.


Что сейчас и что дальше

Проект работает. Бэкенд отдаёт весь пул пользователей одним запросом — при росте до 100+ придётся добавить пагинацию (50 записей на страницу).

У каждого пользователя в профиле — зверушка, назначаемая хешированием имени по алгоритму djb2. 12 штук, все SVG. Алгоритм детерминированный: один и тот же username всегда даёт одну и ту же зверушку.

Сейчас интегрирую платёжный шлюз — API написано, жду одобрения. Если знаете шлюзы для high-risk без необходимости оформлять ИП/СЗ, с нормальным API и Sandbox — пишите в комменты.

В планах: Prometheus + Grafana + Node Exporter для метрик нод, с отображением в профиле пользователя как бизнес-фича.


Весь путь — от первого requests.get() до продакшн-деплоя на пять серверов — занял около трёх месяцев. Не потому что я быстро учусь, а потому что конкретная задача даёт конкретный контекст для обучения. Каждая проблема тянет за собой следующую концепцию, которую нужно понять чтобы двигаться дальше.