kutkha.ru: инженерный девлог
Как собиралась визитка: статика на Next.js, Docker-обвязка, Caddy на периметре и разработка с AI-агентом. По фазам, с решениями и ценой.
На этой странице
Визитка - самая простая вещь на свете. Пока не начинаешь делать её хорошо.
kutkha.ru - это Next.js 16 (App Router) + TypeScript, контент в MDX, сборка в Docker, статика на выходе (dynamic = "force-static", revalidate = false, dynamicParams = false). Никакого SSR в рантайме: страницы запекаются на next build и раздаются готовым HTML через standalone server.js за Caddy. Почта - свой Maddy на том же VPS. Разработка - только в контейнерах, с AI-агентом по контракту из AGENTS.md.
Ниже - как это собиралось, по фазам. В каждой фазе: контекст, решение, цена.
Bootstrap: сначала обвязка, потом код
Самое важное в начале работы над проектом - это настройка среды разработки. Выбор инструментов и их начальная конфигурация. На это не жалко потратить и рабочий день, если понадобится: потом регулярно благодаришь себя за то, что разработка стабильна и предсказуема. И ничего не рассыпается от того, что поленился написать тест или не запретил агенту выполнять команды вроде rm.
Поэтому первый коммит здесь - это уже Dockerized Markdown-сайт: Dockerfile, compose.yaml, структура content/. Не набросок вёрстки, с которым «потом разберёмся», а воспроизводимое окружение с первого дня.
На второй день появился скилл project-quality-audit для AI-агента: проверка кода до того, как кода стало много. Логика та же, что и с окружением: контроль качества дешевле всего обходится, когда он встроен заранее, а не прикручен задним числом. Рядом - мелочь, которая потом сэкономила часы: продакшен-порт с первого дня слушает только loopback, наружу его выпускает исключительно reverse proxy.
К концу недели состоялся переход с react-markdown на полноценный MDX с ревизией оглавления. Соображение было простое: рано или поздно контенту понадобятся интерактивные элементы, и дешевле заложить эту возможность сразу, чем мигрировать потом. Так появились кастомные компоненты прямо в контенте - Image, Callout, Card, TwoCol, InlineNote. Контент остался Markdown-файлом, но внутри него может жить интерактив.
Контент-модель: билингва и один настоящий CSS-дебаг
Меню, схлопнувшееся в полоску
Классическая история: sticky-хедер с фильтром неявно становился containing block для fixed-потомков, и мобильное меню схлопывалось в полоску высотой с сам хедер. Диагностируется такое не сразу, потому что в десктопной вёрстке всё выглядит корректно, а ломается только мобильная панель.
Лечение обошлось без хаков: панель стала рендериться через портал в тело документа и была переоформлена в компактную карточку со скримом.
Два языка
Дальше встала задача билингвы: пары файлов ru/en, URL-префикс для не-дефолтной локали, общий шаблон главной страницы, переключатель локали. Прод-билд тут же нашёл type errors, которых dev-сервер не видел, и с тех пор билд в контейнере стал обязательным шагом перед коммитом - разница между dev и prod окружениями должна всплывать до того, как код ушёл в репозиторий. Туда же заодно:
- фавиконки и webmanifest;
- переезд статики из кастомного роутера в
public/; hreflangи обфускация email.
Perf, hardening и развод репозиториев
Перформанс и гигиена
Обычный набор зрелого проекта, ничего героического: pre-paint theme-скрипт без вспышки неправильной темы (свой скрипт ставит тему синхронно до первой отрисовки, вместо внешней зависимости), авто-размеры изображений из хедеров файлов на билде (нулевой сдвиг layout без ручных размеров в MDX), статический 404 вместо рендера по запросу, снятый x-powered-by. И маленькая радость для коллег, которые откроют DevTools: пасхалка в консоли (позже ужата с PNG до WebP - 62 КБ превратились в 15 КБ base64).
Развод app и infra
Накопленный опыт подсказывает: деплой-конфигурация, лежащая в одном репозитории с приложением, рано или поздно начинает жить своей жизнью. Поэтому deploy/ был вынесен из app-репо в ops-репо: разделение приложения и инфраструктуры на уровне репозиториев, а не папок. Дальше, уже во втором репозитории, собирался периметр: Caddy с модулями кэша и rate limit, per-IP лимиты, in-memory кэш с RAM-лимитами, abort сканеров и ботов, общий блоклист сканеров, скрытие заголовка сервера. Watchtower переведён на polling, состояние инфры зафиксировано в git.
Локально в app-репо в те же дни появился Cache-Control для версионированных ассетов.
Строгий контракт и governance агента
Языковой контракт
Со временем нестрогие соглашения о языках начали мешать: файлы без явного суффикса, тихие фолбэки, рассинхрон переводов. Поэтому был введён строгий контракт: явные суффиксы .ru.mdx/.en.mdx на каждый файл, обязательные URL сайта и дефолтный язык без тихих фолбэков - билд падает с понятной ошибкой вместо того, чтобы молча отдать не то. Директива @include позволяет переиспользовать тела между языками: максимум 5 уровней вложенности, а циклы и выходы за content/ роняют билд, и это правильно - такие вещи должны громко падать на сборке, а не тихо гнить в проде.
Параллельно собиралась система тегов: облако по локалям, страницы тегов, пилюли в статьях. Позже прошла чистка: пилюли стали обычными ссылками без модалки - заодно ушли ловушка фокуса и лишние prefetch-запросы, из-за которых прилетали 429-е. Типичный случай, когда упрощение интерфейса чинит заодно и бэкенд.
Контракт с агентом
Работа с AI-агентом со временем тоже потребовала формализации: появился AGENTS.md, общие правила переехали в глобальный rules-файл, CI-гейт осел в локальной ветке, потому что у токена нет нужного scope. Последнее - честный компромисс, так он и записан в логе: практичное решение вместо идеального, которое откладывалось бы бесконечно.
Своя почта
В ops-репо в те же дни был поднят собственный почтовый сервер: MX, submission, IMAPS, DKIM-подпись исходящих, SPF/DKIM/DMARC-проверки на входе. Сертификат держит Caddy, почта монтирует его в read-only и перечитывает каждую минуту - продление сертификатов работает без хуков и рестартов. Порт :25 у хостера оказался закрыт по умолчанию, открыли по тикету в тот же день, цикл туда-обратно верифицирован. Задокументировано и неудобное: IMAP-хранилище у выбранного сервера - upstream beta, бэкап делается ручным тарболом. Для двух личных ящиков этого достаточно, а честность в документации дешевле, чем сюрпризы потом.
Поиск и последние штрихи
Финалом стал полнотекстовый билингвальный поиск: postbuild индексирует отрендеренный HTML (артефакт сборки, в репозиторий не коммитится), а диалог в хедере подгружает индекс только по первому открытию - до этого момента клиент не получает ни байта поискового кода. Локализация диалога, /-шорткат, фильтр по локали, навигация стрелками и Esc. Статическая модель при этом не пострадала: серверного рантайма по-прежнему нет.
Рядом - мелочи, по которым видно отношение к делу: copy-link на заголовках, флаг hidden (страница собирается и ищется, но остаётся вне навигации, тегов и sitemap, с noindex), пасхалка 42 с обратным отсчётом в том же диалоге.
Что получилось
Если отойти на шаг назад, собранное здесь - давно уже не «сайт-визитка». По итогам работы вырисовывается специфичный продукт:
Custom MDX-driven SSG. Контент - плоские MDX-файлы со строгим контрактом: явные языковые суффиксы, фронтматтер с дюжиной полей (published, draft, hidden, date, tags, cover, order), директива @include с защитой от циклов, стабильные слаги из имён файлов с транслитерацией кириллицы. Сборка полностью статическая, контент запекается в артефакт на билде, рантайм-зависимостей у контента нет.
Flexible portfolio template. Структура выводится из файловой системы: папка с index - раздел, файл - страница, теги - облако и страницы с пер-локализацией. Навигация, хлебные крошки, sitemap, robots.txt, hreflang и канонические URL строятся из одного источника - скана content/. Новый раздел заводится созданием папки, а не правкой кода.
Interactive resume. Страница about показывает, что MDX здесь - не текст с картинками, а layout-система: Card/CardGrid, TwoCol, Callout, Image с авто-размерами из хедеров файлов (нулевой сдвиг layout без ручной рутины), инлайновые заметки-панели с измерением вьюпорта. Резюме собирается как конструктор, а выглядит как продуктовая страница.
MDX blog с поиском без бэкенда. Полнотекстовый билингвальный поиск на индексе сборки: диалог грузит индекс лениво, до первого открытия клиент не получает ни байта поискового кода. Теги независимы по локалям, списки сортируются по дате, черновики и скрытые страницы имеют три чётких состояния видимости (draft/published/hidden).
Personal engineering platform. Обвязка, которая обычно остаётся за кадром: multi-stage Docker (dev/tools/runner, non-root рантайм), контракт с AI-агентом через AGENTS.md со скиллом аудита качества, периметр на Caddy (rate limits, кэш, abort сканеров), собственный почтовый сервер с DKIM, юнит-тесты контент-слоя (53 кейса). Платформа держит два окружения (beta/production) из одного кода сменой env-файла.
Если смотреть на проделанное отстраненно, вырисовывается следующий набор предпочтений:
- статическая генерация вместо динамики там, где динамика не нужна
- контент как коммиты - с диффом, историей и откатом
- инфраструктура как код в git, а не как ручные правки на сервере
- периметр, который молча отбрасывает мусор, не тратя на него ресурсы приложения
- контракт с агентом строже, чем с человеком, потому что агент не задаёт уточняющих вопросов
Отдельно ни один из этих принципов не нов — новизна, если она здесь есть, в том, что они применены все сразу и без исключений. Технологии, на которых это собрано, через несколько лет устареют. Привычка так работать — вряд ли.