Меня зовут Стас. У меня нет опыта в коммерческой разработке — ни фронтенд, ни бэкенд, ни 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() до продакшн-деплоя на пять серверов — занял около трёх месяцев. Не потому что я быстро учусь, а потому что конкретная задача даёт конкретный контекст для обучения. Каждая проблема тянет за собой следующую концепцию, которую нужно понять чтобы двигаться дальше.
