Стек и архитектура моего личного хаба на 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 расширение надо ставить отдельно — сделаю это при переезде, чтобы не тащить лишнее на сервер сейчас.
Параллельно развиваю разделы хаба: «Что я использую» и «Сейчас».
Выводы
Вот что я забираю из этого проекта:
- Инфраструктуру я описываю декларативно. Нативный деплой — тоже конфигурация: vhost, unit, cron и скрипты лежат в репозитории, а не «настроены когда-то на сервере».
- SEO — это схема данных, а не плагин. Поля у модели, sitemap без дублей, значения по умолчанию.
- Дизайн-система окупается на второй странице. CSS-переменные и токены экономят часы на каждой новой секции.
- Меньше JS — меньше поводов его отлаживать. Отказ от библиотеки анимаций дал и вес, и предсказуемость.
- Задел под будущее я держу в репозитории, а не в проде. ASGI и образ с
pgvector— это заготовки, а не работающая на сервере сложность. - Сначала честно посмотреть на сервер, потом рисовать архитектуру. Docker был красивее на бумаге, но на 1,9 ГБ RAM победил нативный вариант.
Если интересна техническая часть — в следующей статье разберу деплой пошагово: systemd, nginx и PostgreSQL на VPS. А почитать остальные записи можно в блоге.
Комментарии