Сайт-портфолио с блогом на Django: от чистой Debian до запущенного сервера
Пошаговая сборка Django-сайта с блогом: окружение и зависимости, модели, админка на Unfold с визуальным редактором, gunicorn под systemd и nginx впереди — с командами, проверками и разбором грабель.
Ниже — весь путь от пустой Debian до работающего сайта-портфолио с блогом: виртуальное окружение, модели, админка с визуальным редактором, gunicorn под systemd и nginx впереди. Команды проверены на Debian 13.7, Python 3.13.5, nginx 1.26.3 и Django 6.0.7. Ничего экзотического не понадобится: только пакеты из репозитория Debian и pip.
Статья рассчитана на то, что вы уже умеете работать в консоли и знаете, что такое SSH. Всё остальное разбирается по шагам: что делаем, зачем и как проверить, что получилось.
1. Что получится
Сайт из двух приложений: core — настройки и маршрутизация, blog — вся предметная часть. Публичная часть состоит из главной страницы, списка статей с фильтром по категориям, страницы статьи, страницы проекта и формы обратной связи. Контентом управляет админка: проекты, публикации, слайды на главной, комментарии и общие настройки сайта.
/var/www/example.com/
├── core/ настройки, urls, wsgi/asgi
├── blog/ модели, вьюхи, админка, шаблонные фильтры
├── templates/ base, index, blog_list, post_detail, project_detail
├── static/ исходники css/js
├── staticfiles/ собранная статика для nginx (в git не входит)
├── media/ загрузки из админки (в git не входит)
├── deploy/ unit systemd, конфиг nginx
├── venv/ виртуальное окружение (в git не входит)
├── .env секреты окружения (в git не входит)
└── manage.py| Маршрут | Что отдаёт |
|---|---|
/ | главная: hero-блок, слайдер, свежие статьи, проекты |
/articles/ | список публикаций, фильтр ?category=blog|news |
/articles/<slug>/ | страница статьи с комментариями |
/projects/<slug>/ | страница проекта |
/contacts/ | форма обратной связи |
/admin/ | управление контентом |
2. Подготовка чистой системы
Ставим ровно то, что нужно: интерпретатор с модулем venv, nginx, git. Отдельный pip не обязателен — внутри окружения он уже есть.
sudo apt update
sudo apt install -y python3 python3-venv nginx gitПакет python3-venv в Debian вынесен отдельно: без него python3 -m venv падает с ensurepip is not available. Проверьте, что модуль на месте, прежде чем идти дальше:
python3 -m venv /tmp/venv-check && rm -rf /tmp/venv-check && echo ok
python3 --versionДальше — отдельный пользователь. Сайт не должен работать от root: gunicorn под root — это лишние права у процесса, который принимает запросы из сети.
sudo adduser deploy
sudo mkdir -p /var/www/example.com
sudo chown deploy:deploy /var/www/example.com3. Виртуальное окружение и зависимости
Окружение живёт внутри каталога проекта, активировать его не нужно — все вызовы идут по абсолютным путям, и это же спасает в systemd, где нет вашей оболочки.
cd /var/www/example.com
python3 -m venv venv
./venv/bin/pip install --upgrade pipЗависимости фиксируются версиями в requirements.txt — так следующий сервер поднимется с тем же набором, а не с «тем, что вышло на этой неделе»:
Django==6.0.7
gunicorn==26.2.0
python-dotenv==1.2.2
django-unfold==0.102.0
django-ckeditor-5==0.2.20
pillow==12.3.0./venv/bin/pip install -r requirements.txtПара замечаний по составу. Django 6.0 требует Python 3.12 и новее — на Debian 13 это 3.13, подходит. gunicorn добавлен в requirements, а не поставлен «мимо»: иначе на новом стенде его просто не будет. Pillow нужен для ImageField (обложки и слайды), Unfold — тема админки, django-ckeditor-5 — визуальный редактор.
4. Скелет проекта
Точка в конце startproject важна: с ней manage.py создаётся в текущем каталоге, а не во вложенном core/. Иначе получится /var/www/example.com/core/manage.py и все дальнейшие команды придётся писать с другим путём.
./venv/bin/django-admin startproject core .
./venv/bin/python manage.py startapp blog
./venv/bin/python manage.py runserver 0.0.0.0:8000Приложение подключается в core/settings.py. Порядок в списке имеет значение для Unfold: тема должна идти до django.contrib.admin, иначе подмены шаблонов не произойдёт.
INSTALLED_APPS = [
"unfold",
"unfold.contrib.filters",
"unfold.contrib.forms",
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"django_ckeditor_5",
"blog",
]5. Модели: проекты, статьи, слайдер, комментарии
Общие поля времени выносятся в абстрактную модель — иначе created_at и updated_at придётся копировать в каждую таблицу и следить, чтобы они не разъехались.
class TimeStampedModel(models.Model):
created_at = models.DateTimeField("Дата создания", auto_now_add=True)
updated_at = models.DateTimeField("Дата изменения", auto_now=True)
class Meta:
abstract = TrueПубликация — одна модель на два раздела: статья и новость различаются полем category, а не отдельными таблицами. Это дешевле в поддержке: список, поиск и страница материала общие.
class Post(TimeStampedModel):
CATEGORY_CHOICES = [("blog", "Блог"), ("news", "Новость")]
title = models.CharField("Заголовок", max_length=250)
slug = models.SlugField("Адрес страницы", unique=True, blank=True)
category = models.CharField("Категория", max_length=10,
choices=CATEGORY_CHOICES, default="blog")
summary = models.TextField("Краткий анонс")
content = CKEditor5Field("Содержание статьи", config_name="extends")
image = models.ImageField("Изображение", upload_to="posts/", blank=True, null=True)
tags = models.CharField("Теги", max_length=200, blank=True)
is_published = models.BooleanField("Опубликовано", default=True)
views = models.PositiveIntegerField("Просмотры", default=0, editable=False)
def save(self, *args, **kwargs):
if not self.slug:
self.slug = unique_slug(self, self.title)
super().save(*args, **kwargs)
def get_absolute_url(self):
return reverse("post_detail", kwargs={"slug": self.slug})Обложка, проекты и слайдер устроены так же. Отдельно стоит запомнить два приёма. Первый: счётчик просмотров помечен editable=False, чтобы его нельзя было случайно переписать из формы в админке. Второй: настройки сайта — синглтон, в таблице всегда одна запись.
class SiteSettings(models.Model):
site_name = models.CharField("Название сайта", max_length=100, default="Example")
tagline = models.CharField("Слоган", max_length=200, default="")
contact_email = models.EmailField("Служебный e-mail", blank=True, default="")
class Meta:
verbose_name = "Настройки сайта"
def save(self, *args, **kwargs):
self.pk = 1
super().save(*args, **kwargs)Чтобы синглтон нельзя было удалить и продублировать, в админке дополнительно переопределяют права: has_add_permission возвращает «записи ещё нет», has_delete_permission — всегда False.
6. Слаги из кириллицы
Штатный slugify() выбрасывает всё, кроме латиницы: из заголовка «Как настроить nginx» получается nginx, а из полностью русского — пустая строка. Два рабочих варианта. Первый — разрешить юникод в поле и в слаге:
from django.utils.text import slugify
slugify("Настройка nginx", allow_unicode=True) # 'настройка-nginx'
# в модели: slug = models.SlugField(allow_unicode=True, ...)Ссылки при этом будут percent-encoded, и это нормально: браузер показывает читаемый адрес, сервер получает закодированный. Второй вариант — транслитерация, если нужны только латинские адреса. Она пишется в двадцать строк и не требует внешних библиотек:
TRANSLIT = {
'а': 'a', 'б': 'b', 'в': 'v', 'г': 'g', 'д': 'd', 'е': 'e', 'ё': 'e',
'ж': 'zh', 'з': 'z', 'и': 'i', 'й': 'j', 'к': 'k', 'л': 'l', 'м': 'm',
'н': 'n', 'о': 'o', 'п': 'p', 'р': 'r', 'с': 's', 'т': 't', 'у': 'u',
'ф': 'f', 'х': 'h', 'ц': 'c', 'ч': 'ch', 'ш': 'sh', 'щ': 'sch',
'ъ': '', 'ы': 'y', 'ь': '', 'э': 'e', 'ю': 'yu', 'я': 'ya',
}
def translit_slugify(value: str) -> str:
out = []
for ch in (value or "").lower():
if ch in TRANSLIT:
out.append(TRANSLIT[ch])
elif ch.isalnum() and ch.isascii():
out.append(ch)
else:
out.append("-")
slug = "".join(out)
while "--" in slug:
slug = slug.replace("--", "-")
return slug.strip("-")[:200]
def unique_slug(instance, value: str, field: str = "slug") -> str:
base = translit_slugify(value) or "zapis"
slug, counter = base, 2
while instance.__class__.objects.filter(**{field: slug}).exclude(pk=instance.pk).exists():
slug = f"{base}-{counter}"
counter += 1
return slugПроверка уникальности обязательна: два материала с одинаковым заголовком дадут IntegrityError на unique=True. Суффикс -2 решает это без ручной правки слага в админке.
7. Настройки, которые зависят от окружения
Секретный ключ, режим отладки и список хостов не должны лежать в репозитории. Читаем их из .env через python-dotenv, а в коде держим два маленьких помощника — иначе разбор булевых значений и списков расползается по файлу.
import os
from pathlib import Path
from dotenv import load_dotenv
BASE_DIR = Path(__file__).resolve().parent.parent
load_dotenv(BASE_DIR / ".env")
def env_bool(name: str, default: bool = False) -> bool:
value = os.getenv(name)
if value is None:
return default
return value.strip().lower() in ("1", "true", "yes", "on")
def env_list(name: str, default: str = "") -> list[str]:
raw = os.getenv(name, default)
return [item.strip() for item in raw.split(",") if item.strip()]
SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", "django-insecure-change-me")
DEBUG = env_bool("DJANGO_DEBUG", default=False)
ALLOWED_HOSTS = env_list("DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1")
CSRF_TRUSTED_ORIGINS = env_list("DJANGO_CSRF_TRUSTED_ORIGINS")Файл .env целиком уходит в .gitignore, а в репозиторий кладётся .env.example с описанием переменных — по нему поднимают следующий стенд:
DJANGO_SECRET_KEY=change-me
DJANGO_DEBUG=True
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,192.0.2.10
DJANGO_CSRF_TRUSTED_ORIGINS=http://192.0.2.10,http://localhost:8000Три настройки, на которых чаще всего спотыкаются:
- ALLOWED_HOSTS должен содержать IP или имя, по которому вы открываете сайт. Без него Django отвечает
400 Bad Requestна полностью рабочий сервер, и это выглядит как поломка nginx. - CSRF_TRUSTED_ORIGINS пишется со схемой (
http://192.0.2.10), иначе вход в админку за прокси не пройдёт проверку CSRF. - STATIC_ROOT обязателен: без него
collectstaticнекуда складывать файлы, и nginx нечего отдавать.
LANGUAGE_CODE = "ru-ru"
TIME_ZONE = "Europe/Moscow"
USE_I18N = True
USE_TZ = True
STATIC_URL = "static/"
STATIC_ROOT = BASE_DIR / "staticfiles"
STATICFILES_DIRS = [BASE_DIR / "static"]
MEDIA_URL = "/media/"
MEDIA_ROOT = BASE_DIR / "media"
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.filebased.FileBasedCache",
"LOCATION": BASE_DIR / "cache",
"TIMEOUT": 600,
}
}Кэш на файлах, а не в памяти процесса, нужен, когда gunicorn работает в несколько воркеров: лимиты отправки форм и антиспам должны быть общими, иначе счётчик у каждого воркера свой и ограничение обходится вторым запросом.
8. Админка: Unfold и визуальный редактор
Подключаем редактор: приложение, настройки панели инструментов и маршрут для загрузки картинок. Без маршрута кнопка «вставить изображение» в редакторе отвечает 404.
CKEDITOR_5_CONFIGS = {
"extends": {
"language": "ru",
"height": 420,
"toolbar": [
"heading", "|", "bold", "italic", "strikethrough", "underline", "code",
"subscript", "superscript", "|",
"link", "uploadImage", "insertTable", "blockQuote", "codeBlock", "|",
"bulletedList", "numberedList", "todoList", "|", "outdent", "indent", "|",
"undo", "redo",
],
},
}# core/urls.py
urlpatterns = [
path("admin/", admin.site.urls),
path("ckeditor5/", include("django_ckeditor_5.urls")),
path("", include("blog.urls")),
]Русская админка требует LocaleMiddleware сразу после SessionMiddleware. Без него Django не выбирает язык запроса, и LANGUAGE_CODE = "ru-ru" остаётся красивой строкой в настройках, а интерфейс — английским.
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.locale.LocaleMiddleware",
"django.middleware.common.CommonMiddleware",
# ...
]В UNFOLD задаются название панели, палитра и навигация по разделам. Навигация собирается списком словарей: title, icon (имя иконки Material) и link через reverse_lazy("admin:blog_post_changelist"). Ссылка на сам сайт ставится с attrs={"target": "_blank"}.
Для рабочего стола с цифрами используется UNFOLD["DASHBOARD_CALLBACK"] — функция в blog/admin.py, которая считает статистику и отдаёт её в контекст. Считать в шаблоне запросами к базе не надо: шаблон выполняется на каждый заход.
9. Шаблоны и статика
Базовый шаблон держит каркас, а {% block content %} не оборачивается в контейнер: секции на всю ширину (hero, слайдер) иначе не растягиваются, и приходится спасаться отрицательными отступами. Контейнер добавляют внутри конкретной секции, где он нужен.
<link rel="stylesheet" href="{% static 'css/site.css' %}?v={{ asset_version }}">
<script src="{% static 'js/site.js' %}" defer></script>Параметр v — версия статики из контекст-процессора: она равна максимальному mtime файлов static/css/*.css и static/js/*.js. Без него nginx с expires 7d неделю отдаёт браузеру старый CSS, и правка «не применяется». systemctl restart кеш браузера не сбрасывает — только новое имя файла или параметр.
Стили лучше держать свои, в одном site.css, а не тянуть Tailwind и Swiper с CDN: внешняя зависимость в публичной части — это и лишний запрос, и чужой простой, и «шаблонный» вид. Значения цветов, теней и типографики в переменных :root, иначе светлая и тёмная темы начинают спорить друг с другом.
10. gunicorn под systemd
Сервер приложений слушает только 127.0.0.1 — наружу его выставляет nginx. Юнит лежит в deploy/ и копируется в /etc/systemd/system/, чтобы конфигурация не терялась при переустановке.
[Unit]
Description=example.com (Django + Gunicorn)
After=network.target
[Service]
Type=notify
User=deploy
Group=deploy
WorkingDirectory=/var/www/example.com
Environment=PYTHONUNBUFFERED=1
ExecStart=/var/www/example.com/venv/bin/gunicorn core.wsgi:application \
--bind 127.0.0.1:8001 \
--workers 2 \
--timeout 60 \
--access-logfile - \
--error-logfile -
ExecReload=/bin/kill -s HUP $MAINPID
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.targetsudo cp deploy/example.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now example
systemctl status example --no-pagerПуть к gunicorn — абсолютный. В systemd нет вашего PATH, поэтому ExecStart=gunicorn ... заканчивается status=203/EXEC или «command not found», хотя в терминале та же команда работает. Type=notify позволяет системе знать момент готовности воркеров, а Restart=always поднимает процесс после падения.
11. nginx
Сначала убираем дефолтную заглушку. При server_name _ она продолжает отвечать на запросы, и вы отлаживаете свой конфиг, глядя на чужой ответ.
sudo rm -f /etc/nginx/sites-enabled/default
sudo cp deploy/nginx-site.conf /etc/nginx/sites-available/example.com
sudo ln -sf /etc/nginx/sites-available/example.com /etc/nginx/sites-enabled/example.comСерверный блок: статика и медиа отдаются файлами, всё остальное проксируется в gunicorn.
server {
listen 80 default_server;
server_name _;
client_max_body_size 25m;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
location ~ /\.(?!well-known) {
deny all;
}
location /static/ {
alias /var/www/example.com/staticfiles/;
expires 7d;
access_log off;
}
location /media/ {
alias /var/www/example.com/media/;
expires 7d;
access_log off;
}
location / {
proxy_pass http://127.0.0.1:8001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_redirect off;
}
}Заголовки безопасности дублируются с SecurityMiddleware — получаются две копии в ответе. Это не ошибка, но мусор: в location добавьте proxy_hide_header X-Content-Type-Options; и то же для X-Frame-Options и Referrer-Policy. Директивы add_header пишутся с always, иначе на ответах 4xx и 5xx заголовков не будет вообще.
Ограничения частоты объявляются в /etc/nginx/conf.d/limits.conf — это http-контекст, внутри server они не работают:
limit_req_zone $binary_remote_addr zone=site:10m rate=240r/m;
limit_req_zone $binary_remote_addr zone=login:10m rate=6r/m;
limit_req_status 429;Дальше в нужных location ставится limit_req zone=login burst=5 nodelay;. Жёсткий лимит — только на вход в админку (location = /admin/login/): 6 запросов в минуту. На всю админку такой лимит ставить нельзя, серия переходов по разделам упирается в 429 и выглядит как «админка сломалась» — рабочий диапазон там 300r/m с burst 60. Страницу 429 делайте свою через error_page 429 @too_many;, иначе пользователь видит голую заглушку nginx.
sudo /usr/sbin/nginx -t && sudo systemctl reload nginxБинарь по абсолютному пути: у непривилегированного пользователя nginx не в PATH, и nginx -v отвечает «команда не найдена» — это не признак, что nginx не установлен.
12. Первый запуск и проверка
Миграции, статика, администратор. Порядок важен: check ловит битые настройки за секунду, до того как вы начнёте разбираться с ошибками базы.
./venv/bin/python manage.py check
./venv/bin/python manage.py migrate
./venv/bin/python manage.py collectstatic --noinputПароль суперпользователя не должен появляться в командной строке: она попадает в историю оболочки и в логи. Генерируем его в переменную и передаём через stdin:
PW=$(python3 -c "import secrets,string;a=string.ascii_letters+string.digits;print(''.join(secrets.choice(a) for _ in range(18)))")
printf '%s' "$PW" | DJANGO_SUPERUSER_PASSWORD="$PW" ./venv/bin/python manage.py createsuperuser \
--noinput --username admin --email admin@example.comПроверять надо не «открывается ли главная», а конкретные маршруты и коды ответов:
for u in / /articles/ /admin/login/ /static/css/site.css; do
printf '%-28s %s\n' "$u" "$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1$u)"
done
curl -sI http://127.0.0.1/ | grep -i -E 'content-security|x-frame|referrer'Ожидаются 200 на страницах и статике. 400 — почти всегда ALLOWED_HOSTS, 502 — gunicorn не запустился, 404 на /static/ при запросе на порт 8001 — норма, статику раздаёт nginx.
Вход в админку проверяйте тем паролем, который выдавали, а не фактом создания пользователя. И держите в проекте тесты на публичные страницы: они ловят и NoReverseMatch в переопределённых шаблонах, и забытые миграции.
./venv/bin/python manage.py test13. Обновление сайта
Бэкап — до правок, а не после. На живой базе SQLite достаточно копии файла, но делать её надо при остановленном приложении или через .backup:
cd /var/www/example.com
mkdir -p ~/backups
sqlite3 db.sqlite3 ".backup '$HOME/backups/db-$(date +%Y%m%dT%H%M%S).sqlite3'"
git pull
./venv/bin/python manage.py migrate
./venv/bin/python manage.py collectstatic --noinput
sudo systemctl restart example
./venv/bin/python manage.py testНе оборачивайте эту цепочку в set -e с одной проверкой в конце: упавший git pull обрывает выполнение, вывод пустой, код остаётся старым, а выглядит как успешный деплой. Проверяйте git log -1 --oneline, git status --porcelain и systemctl is-active example после каждой выкладки.
14. Грабли
- Сайт отвечает 400 на рабочий сервер. В
ALLOWED_HOSTSнет IP или имени, по которому вы зашли. Ошибка в настройке, а не в nginx. - Status=203/EXEC у юнита. В
ExecStartотносительный путь к gunicorn; нужен абсолютный/var/www/example.com/venv/bin/gunicorn. - Скрипт из
/tmpпадает с ModuleNotFoundError. Вsys.pathпопадает каталог скрипта, а не корень проекта: запускайте сPYTHONPATH=/var/www/example.comили черезmanage.py shell -c. - Из русского заголовка получается пустой слаг.
slugify()вырезает кириллицу: нуженallow_unicode=Trueили транслитерация. - Правка CSS не видна. Кеш статики: добавьте
?v={{ asset_version }}и проверьте, что nginx отдаёт новый файл. - Админка осталась английской. Нет
LocaleMiddlewareпослеSessionMiddleware. - makemigrations не видит изменений. Миграция генерируется там, где лежит база: сначала выложите код на сервер, потом создавайте миграцию на нём.
- Новый заголовок не появился после reload. Мастер nginx перечитывает конфиг асинхронно, старые воркеры ещё отдают прежний ответ. Проверяйте
nginx -T | grep <директива>и повторитеcurl -sIчерез секунду. - Деплой «прошёл», а сайт старый. После правок Python-кода нужен
systemctl restart, после правок статики —collectstatic. Git pull сам по себе не меняет ничего в работающем процессе. - Секреты в репозитории.
.env,db.sqlite3,media/,venv/иstaticfiles/— в.gitignore; уже закоммиченное убирается черезgit rm --cached, файл остаётся на диске.
15. Шпаргалка
| Задача | Команда |
|---|---|
| Проверить настройки | ./venv/bin/python manage.py check |
| Применить миграции | ./venv/bin/python manage.py migrate |
| Собрать статику | ./venv/bin/python manage.py collectstatic --noinput |
| Создать администратора | DJANGO_SUPERUSER_PASSWORD=... manage.py createsuperuser --noinput |
| Перезапустить приложение | sudo systemctl restart example |
| Проверить конфиг nginx | sudo /usr/sbin/nginx -t |
| Перечитать конфиг nginx | sudo systemctl reload nginx |
| Логи приложения | journalctl -u example -n 100 --no-pager |
| Резервная копия базы | sqlite3 db.sqlite3 ".backup 'backup.sqlite3'" |
| Тесты | ./venv/bin/python manage.py test |
Дальше начинается наполнение: проекты, статьи, слайды. Техническая часть на этом заканчивается — сайт работает, обновляется одной серией команд и переживает падение процесса.
Комментарии 0
Комментариев пока нет — будьте первым.
Оставить комментарий
Имя и текст — этого достаточно. Комментарий появится сразу; подозрительные сообщения уходят на проверку.