DjangoLinuxnginx

Сайт-портфолио с блогом на 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.com

3. Виртуальное окружение и зависимости

Окружение живёт внутри каталога проекта, активировать его не нужно — все вызовы идут по абсолютным путям, и это же спасает в 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.target
sudo 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 test

13. Обновление сайта

Бэкап — до правок, а не после. На живой базе 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
Проверить конфиг nginxsudo /usr/sbin/nginx -t
Перечитать конфиг nginxsudo systemctl reload nginx
Логи приложенияjournalctl -u example -n 100 --no-pager
Резервная копия базыsqlite3 db.sqlite3 ".backup 'backup.sqlite3'"
Тесты./venv/bin/python manage.py test

Дальше начинается наполнение: проекты, статьи, слайды. Техническая часть на этом заканчивается — сайт работает, обновляется одной серией команд и переживает падение процесса.

Комментарии 0

Комментариев пока нет — будьте первым.

Оставить комментарий

Имя и текст — этого достаточно. Комментарий появится сразу; подозрительные сообщения уходят на проверку.

Сдвиньте ползунок вправо для проверки
Спам и реклама блокируются автоматически.
Читайте также

Похожие публикации

04 Ноя

Заметки по Django 6: что изменилось на практике

Локаль, часовой пояс, настройки через .env и немного про админку Unfold.

→