Стек и архитектура моего личного хаба на Django

Разбираю свой личный сайт-хаб: как он устроен внутри, какие решения я принял и что бы сделал иначе.

Этот сайт — не «блог на CMS», а мой рабочий инструмент. Он собирает в одном месте экосистему проектов, статьи, музыку, а в перспективе — и AI-ассистента, который отвечает на вопросы по этому же контенту.

Сделать решил сам, а не завести профиль на платформе, по простой причине: itchalpanov.ru — про услуги и кейсы, для «пути, ошибок и экспериментов» места не было, а смешивать коммерцию с личным не хотелось. Архитектура Django-проекта здесь выросла из этой задачи — сделать так, чтобы место с моими материалами принадлежало мне целиком. Ниже — как оно устроено, что я переделывал и где до сих пор плачу за свои решения.

Задача: хаб, а не визитка

Требования я сформулировал так:

  • Быстрый старт публикаций. Чтобы выпустить статью, не нужно вспоминать, как работает сборка.
  • Полный контроль над SEO. Метаданные, канонические URL, sitemap, структурированные данные — всё редактируемо.
  • Личный голос. Дизайн не должен выглядеть как шаблон: никаких конструкторов и стандартных тем.
  • Готовность к AI. Задел под семантический поиск и ассистента, а не «когда-нибудь потом».
  • Простой деплой. Один сервер, минимум сущностей, HTTPS без ручных танцев с сертификатами.

Что недооценил на старте — мобильные мелочи: safe-area, шапка в 56 px, вес картинок под LCP. На бумаге всё это выглядело неважным, а на телефоне именно оно и решало, будет сайт ощущаться быстрым или нет.

Позже добавились «Что я использую» и «Сейчас» — короткие срезы автора, которые быстро обновляются. В плане их не было, а оказались они полезнее, чем я ожидал.

Стек: что под капотом

Слой Выбор Почему
Язык Python 3.12 основной рабочий язык
Фреймворк Django 5.2 LTS админка, ORM, миграции, sitemap — из коробки
БД PostgreSQL 14 уже стоял на VPS; второй сервер баз не нужен
Кэш Redis кэш и сессии
Стили Tailwind CSS 3 дизайн-система на CSS-переменных
Сборка JS Vite 6 быстрая сборка, без фреймворка
JS vanilla тема, меню, режим чтения — десятки строк
Сервер uvicorn (ASGI) стриминг для AI-чата в будущем
Прокси nginx уже работал на сервере, добавлен только vhost
Статика WhiteNoise хеши в именах файлов и кэш на год

Тяжелее всего в стеке далось не что-то архитектурное, а переписывание анимаций так, чтобы в кадрах скролла не осталось ни одного чтения геометрии. Об этом — в разделе «Что не прижилось».

Ключевое решение — Django, а не статический генератор. Для блога с одной публикацией в неделю генератор был бы быстрее, но мне нужна динамика: админка, комментарии с модерацией, редактирование страниц, редиректы со старых адресов, а дальше — API для ассистента.

Версия фреймворка менялась по ходу: проект начинался на Django 3.2, потом был переход на 4.2, а перед деплоем — на 5.2 LTS, потому что у 4.2 закончилась поддержка с патчами безопасности. Одна поломка на последнем переходе была неочевидной: выход из аккаунта через GET стал возвращать 405 — в Django 5 это только POST. Хорошо, что к тому моменту уже были тесты: поломка нашлась сразу, а не в проде.

Структура проекта: три слоя

Проект разделён так, чтобы блог, хаб и каркас не мешали друг другу:

mysite/
  settings/          base.py · local.py · production.py
  urls.py asgi.py wsgi.py
blog/                посты, категории, теги, комментарии, SEO-слой
pages/               хаб: главная, проекты, музыка, «использую», «сейчас»
assets/css/          исходники дизайн-системы
frontend/            JS-исходники (Vite)
deploy/              нативный деплой, заготовка в Docker, бэкапы, cron
content/drafts/      черновики статей
docs/                бриф стиля, SEO-ключи и чеклист

Настройки разбиты на три файла, выбор окружения — одной переменной:

# mysite/settings/__init__.py
from decouple import config

if config('DJANGO_ENV', default='local').strip().lower() == 'production':
    from .production import *
else:
    from .local import *

Локально — SQLite и DEBUG=True, на сервере — Postgres, Redis, HTTPS и WhiteNoise. Условий if settings.DEBUG по коду нет: за окружение отвечает только конфигурация.

Что в структуре пришлось менять по ходу — схему URL: /blog/post/<слаг>//blog/<слаг>/ плюс миграция слагов в ASCII. Работает, но лучше было сделать это сразу.

Дизайн-система: CSS-переменные вместо хардкода

Главная проблема кастомного дизайна — расползающиеся цвета и отступы. Я решил её на уровне токенов: цвета живут в CSS-переменных, а Tailwind ссылается на них.

:root {
  --color-bg: #faf8f4;
  --color-text: #1c1917;
  --brand-color: #6b3a42;
}
[data-theme='dark'] {
  --color-bg: #141210;
  --color-text: #ede8e3;
  --brand-color: #c4868f;
}
// tailwind.config.js
colors: {
  bg: 'var(--color-bg)',
  foreground: 'var(--color-text)',
  primary: { DEFAULT: '#6b3a42' },
},

Тема — три состояния: светлая (по умолчанию), тёмная и «как в системе». Выбор сохраняется в localStorage, а короткий скрипт в <head> выставляет data-theme до первой отрисовки: иначе при тёмной теме страница успевает мигнуть белым.

Две вещи оказались полезнее, чем я ожидал:

  • Режим чтения — прячет навигацию и оставляет текст статьи с полосой прогресса.
  • Оглавление — строится из H2 при рендере Markdown и выводится в начале статьи.

К палитре и типографике я пришёл не сразу. У меня два сайта, и они не должны выглядеть близнецами: на коммерческом акцент янтарный (#b45309), здесь — бордовый (#6b3a42). Заголовки набраны серифным Source Serif 4, текст — Onest; оба шрифта лежат у меня же в woff2 и подключаются с preload, без обращения к Google на каждой загрузке.

Ещё одна деталь из той же серии — стеклянная шапка. Она проявляется при скролле: полупрозрачная подложка плюс backdrop-filter. На iOS до 18 нужен префикс -webkit-backdrop-filter, и тут был сюрприз: сборщик выкидывал его из готового CSS, потому что цели браузеров не были заданы явно. Помогло две правки: browserslist в package.json и перенос входного файла Tailwind в корень проекта — autoprefixer ищет настройки рядом с обрабатываемым файлом, а он лежал во временном каталоге.

SEO-слой: то, что обычно откладывают на потом

SEO я закладывал в модель данных, а не прикручивал плагином. У поста есть поля, которые заполняются прямо в редакторе:

class Post(models.Model):
    meta_title = models.CharField(max_length=70, blank=True)
    meta_description = models.CharField(max_length=160, blank=True)
    focus_keyword = models.CharField(max_length=120, blank=True)
    og_image = models.ImageField(upload_to='og/', blank=True, null=True)
    canonical_url = models.URLField(blank=True)
    noindex = models.BooleanField(default=False)
    cover_alt = models.CharField(max_length=200, blank=True)

Пустое поле не блокирует публикацию: подставляется значение по умолчанию. Но если статья важная, метаданные заполняются руками — в админке видно, что именно уйдёт в выдачу и чего не хватает.

Структурированные данные собираются в одном месте и отдаются через django-meta: BlogPosting с датами и автором, BreadcrumbList, на списках блога — Blog, на главной — WebSite с SearchAction и Person.

Отдельная история — sitemap. Сначала каждому проекту и альбому ставился один и тот же URL раздела, и в карту сайта попадали дубли: /projects/ повторялся столько раз, сколько было проектов. Для поисковика это сигнал «мусорные адреса». Нашлось это при ручной проверке. Сейчас разделы перечислены ровно один раз:

# pages/sitemaps.py (импорты опущены)
class StaticViewSitemap(Sitemap):
    changefreq = 'monthly'
    priority = 0.8

    def items(self):
        return ['home', 'about', 'uses', 'now', 'projects', 'music', 'contact']

    def location(self, item):
        return reverse(item)

Рядом — мелочи, которые лень делать потом: динамический robots.txt с Clean-param для UTM-меток, noindex на страницах поиска и пагинации, черновики отдают 404 посторонним и X-Robots-Tag: noindex, nofollow автору, RSS на /blog/feed/.

Деплой: почему нативный вариант, а не Docker

Сначала я расписал прод в docker compose: приложение, база, кэш, Caddy и сервис бэкапов. А потом посмотрел на сервер: 1 vCPU, 1.9 ГБ RAM, 15 ГБ диска и уже два живых проекта, которые занимают nginx 1.18, PostgreSQL 14 и Redis. Поднимать второй Postgres и второй Redis в контейнерах на такой машине — это плюс гигабайт диска и полторы сотни мегабайт памяти ради того, что на сервере уже работает.

Выбрал нативный вариант:

  • приложение — uvicorn mysite.asgi:application под systemd на 127.0.0.1:8001 (порт 8000 занят соседним проектом);
  • HTTPS — nginx и certbot, добавился только новый vhost;
  • статика — WhiteNoise: хеш в имени файла и кэш на год;
  • база и кэш — системные PostgreSQL 14 и Redis: взял свободную базу db4, потому что db2 и db3 заняты другими проектами;
  • бэкапы — ежедневный pg_dump по cron, медиа синхронизируется в объектное хранилище;
  • /healthz/ — простая ручка для мониторинга.

Важная деталь: CSS и JS собираются локально и коммитятся. Node на сервере не нужен вообще, а деплой сводится к git pull, миграциям и перезапуску сервиса.

Медиа лежит вне БД, поэтому одного pg_dump мало — нужна ещё синхронизация файлов. Это из тех вещей, о которых вспоминаешь, когда уже поздно, поэтому в бэкап-скрипте она у меня с самого начала.

Docker-вариант я не выбросил. Dockerfile с многоэтапной сборкой (фронтенд собирается в node:22-alpine, в рантайм уезжают только артефакты) и compose с Caddy и образом pgvector лежат в репозитории как заготовка на будущее — когда будет VPS с запасом памяти. Там же пригодился файл про зеркала реестра: из России docker pull без registry-mirrors отваливается по таймауту.

Что не прижилось

Часть решений я переделывал, и для меня это нормальная часть работы.

  • Библиотека анимаций. На главной сначала стоял GSAP. Я выбросил его целиком: анимации героя переехали на чистые CSS-@keyframes и разбиение заголовка по словам, а привязка к скроллу — на animation-timeline: view() под @supports. Из кадров скролла пропали все замеры геометрии: они пересчитываются на инициализации, на ресайзе и через ResizeObserver. Выигрыш не только в весе — эффекты перестали дёргаться, а при prefers-reduced-motion отключаются совсем.
  • Слой «атмосферы». Пятна, сетка, зерно и виньетка неплохо смотрелись на десктопе, но сетка была с маской-эллипсом: её линии читались как граница двух тонов поверх текста. Плюс слой фиксированный — при скролле висел над контентом. Убрал; шаблон и стили остались и включаются одной строкой.
  • Шапка на телефоне. Сначала я сделал её фоном в цвет страницы, без рамок и тени: швов не стало, но и перестало быть понятно, где шапка. Вернул стекло, только подложка берётся от цвета страницы, а не от поверхности — контраст появился, «ступенька» не вернулась.
  • content-visibility. Соблазнительно для длинных списков, но на мобильных давало прыжок скролла: реальная высота секции зависит от числа постов. Отказался осознанно.
  • Старые адреса. Первые версии отдавали статьи по /blog/post/<слаг>/. Сейчас они редиректятся на /blog/<слаг>/ постоянным 301. Работает, но лучше было сразу сделать «правильные» пути — меньше редиректов в истории.

Что дальше

Следующий слой — AI-ассистент: посетитель задаёт вопрос, система ищет ответ по моим же статьям и проектам и отвечает со ссылками на источники.

Задел уже есть. Приложение запущено под ASGI, чтобы отдавать ответ потоком, а не ждать целиком. С векторным поиском чуть сложнее: в Docker-варианте база взята образом с pgvector, а на нативной PostgreSQL 14 расширение надо ставить отдельно — сделаю это при переезде, чтобы не тащить лишнее на сервер сейчас.

Параллельно развиваю разделы хаба: «Что я использую» и «Сейчас».

Выводы

Вот что я забираю из этого проекта:

  1. Инфраструктуру я описываю декларативно. Нативный деплой — тоже конфигурация: vhost, unit, cron и скрипты лежат в репозитории, а не «настроены когда-то на сервере».
  2. SEO — это схема данных, а не плагин. Поля у модели, sitemap без дублей, значения по умолчанию.
  3. Дизайн-система окупается на второй странице. CSS-переменные и токены экономят часы на каждой новой секции.
  4. Меньше JS — меньше поводов его отлаживать. Отказ от библиотеки анимаций дал и вес, и предсказуемость.
  5. Задел под будущее я держу в репозитории, а не в проде. ASGI и образ с pgvector — это заготовки, а не работающая на сервере сложность.
  6. Сначала честно посмотреть на сервер, потом рисовать архитектуру. Docker был красивее на бумаге, но на 1,9 ГБ RAM победил нативный вариант.

Если интересна техническая часть — в следующей статье разберу деплой пошагово: systemd, nginx и PostgreSQL на VPS. А почитать остальные записи можно в блоге.

Комментарии

Войдите, чтобы комментировать от своего имени.