Для production-развертывания Django одного python manage.py runserver недостаточно. В рабочей схеме Django отвечает за логику приложения, PostgreSQL хранит данные, Gunicorn запускает Python-приложение, systemd управляет процессом, а Nginx принимает внешний HTTP/HTTPS-трафик и раздает static/media.
В этом руководстве мы:
- Подготовили Ubuntu и установили Python, venv и системные зависимости;
- Перенесли Django-проект на VPS;
- Создали виртуальное окружение и установили зависимости;
- Установили PostgreSQL, создали отдельную базу и пользователя;
- Вынесли SECRET_KEY, пароль базы, DEBUG и ALLOWED_HOSTS в .env;
- Применили миграции и собрали static через collectstatic;
- Запустили Django через Gunicorn на 127.0.0.1:8000;
- Оформили Gunicorn как systemd-сервис с автозапуском;
- Настроили Nginx как reverse proxy;
- Передали static и media напрямую Nginx;
- Подключили домен;
- Получили TLS-сертификат через Certbot и включили HTTPS;
- Разобрали диагностику 502 Bad Gateway, проблем PostgreSQL, static/media и DisallowedHost.
В результате Django-приложение работает как нормальный production-сервис: поднимается после перезагрузки VPS, использует отдельную PostgreSQL-базу, не хранит секреты прямо в коде и доступно пользователям по HTTPS через Nginx.
От пустого VPS до production-Django: что мы вообще строим
Добро пожаловать!
Сегодня мы снова возвращаемся к кодингу и командам, но задача стоит уже посерьезнее.
На этот раз будем разворачивать Django-приложение на Ubuntu и постепенно доведем его от обычного проекта до нормальной production-схемы.
Для начала коротко разберемся с самим Django.
Django — это веб-фреймворк для Python, то есть готовый набор инструментов для разработки сайтов, API и других веб-приложений.
Он уже умеет многое из того, что иначе пришлось бы собирать вручную:
- Обрабатывать URL и запросы пользователей;
- Работать с базой данных через ORM;
- Использовать шаблоны;
- Создавать административную панель;
- Управлять пользователями и авторизацией;
- Подключать middleware и другие компоненты приложения.
Проще говоря, Python дает нам язык, а Django — готовый каркас, на котором можно строить веб-приложение.
Например:

Но сам факт того, что проект написан на Django и запускается локально, еще не делает его готовым к публичной работе.
Наша цель — пройти весь путь от пустого Ubuntu-сервера до схемы, которая уже похожа на нормальное production-развертывание.
Допустим, у нас есть готовый Django-проект.
На локальном компьютере он может прекрасно запускаться командой: python manage.py runserver
И на первый взгляд возникает вполне логичный вопрос: «Если сайт уже работает, зачем вообще добавлять PostgreSQL, Gunicorn, systemd, Nginx и SSL?»
Потому что локальная разработка и публичный сервер решают разные задачи.
На своем компьютере нам важно быстро запустить проект, увидеть изменения и продолжить писать код.
На production-сервере уже хочется другого. Чтобы:
- Приложение переживало перезагрузку VPS;
- База данных нормально хранила рабочие данные;
- Сайт открывался через домен;
- Соединение было защищено HTTPS;
- Static и media корректно отдавались пользователю;
- Приложение автоматически возвращалось в работу после сбоя;
- Наружу не приходилось выставлять внутренний порт Django.
Поэтому итоговый стек получится многослойным.
Но пугаться количества компонентов не стоит. У каждого здесь одна вполне понятная роль, и дальше мы будем добавлять их постепенно.
Почему python manage.py runserver недостаточно
Начнем с привычного: python manage.py runserver
Эта команда запускает встроенный development server Django.
И ключевое слово здесь именно development.
Он создавался в первую очередь для разработки: быстро поднять проект, посмотреть страницу, проверить код и продолжить работу.
Например:

Для локальной машины это прекрасно.
Но на публичном сервере требования меняются.
Нам уже нужно принимать реальные запросы пользователей, нормально управлять процессом приложения и не зависеть от открытой SSH-сессии.
Представим, что мы просто зашли на VPS и выполнили: python manage.py runserver 0.0.0.0:8000
Сайт может открыться.
Но затем мы закрыли терминал, процесс завершился — и приложение исчезло вместе с ним.
Даже если запустить его каким-нибудь обходным способом в фоне, остается другой вопрос: кто поднимет Django после перезагрузки сервера или падения процесса?
Кроме того, сам Django прямо рассматривает runserver как инструмент разработки, а не production-сервер.
Поэтому в нашей схеме его место займет Gunicorn.
Условно:
- Runserver → удобно разработчику
- Gunicorn → предназначен для запуска Python web-приложения в production
Но и Gunicorn не будет решать вообще все задачи самостоятельно.
И здесь как раз становится понятно, зачем нам понадобятся остальные компоненты.
Кто за что отвечает: Django, Gunicorn, PostgreSQL, systemd и Nginx
Посмотрим на весь стек как на команду, где у каждого участника своя работа.
Django — про него мы уже говорили, это само приложение.
Именно здесь находится:
- Бизнес-логика;
- Маршруты;
- Модели;
- Шаблоны;
- API;
- Административная панель;
- Работа с данными.
То есть Django отвечает на вопрос: «Что должен сделать сайт, когда пользователь отправил запрос?»
Но сам по себе framework не обязан заниматься всеми инфраструктурными задачами вокруг этого запроса.
Далее у нас идёт PostgreSQL. Он будет хранить постоянные данные приложения.
Например:
- Пользователи
- Заказы
- Публикации
- Настройки
- Комментарии
Django обращается к PostgreSQL через ORM, а база уже отвечает за реальное хранение этих записей.
Вместо схемы:

Мы придем к:

Почему для production мы будем использовать PostgreSQL, а не оставим стандартный SQLite, отдельно разберем чуть позже.
После идёт Gunicorn. Он становится промежуточным слоем между внешним веб-сервером и Django.
Именно он будет запускать наше Python-приложение и принимать запросы, которые затем обрабатывает Django.

Если провести простую аналогию, Django можно представить как кухню ресторана, где реально готовится заказ.
Gunicorn в таком случае — официант, который принимает заказ и относит его на кухню.
Но посетителей мы всё равно не будем отправлять напрямую к официанту через черный вход.
Перед ним появится еще один слой — Nginx.
Nginx станет внешней точкой входа.
Он будет принимать запросы пользователей на стандартных портах:
- 80
- 443
А затем передавать динамические запросы Gunicorn.
Кроме того, Nginx возьмет на себя:
- Работу с доменом;
- HTTPS;
- Static-файлы;
- Media-файлы;
- Proxy-заголовки.
То есть:

При этом Gunicorn можно вообще не выставлять напрямую в интернет.
Остается еще один участник — systemd.
Его задача не обрабатывать HTTP-запросы и не работать с базой.
Он будет следить за самим процессом Gunicorn.
Например:

Или Gunicorn неожиданно остановился — systemd позволяет централизованно управлять сервисом, смотреть его состояние и логи.
В итоге роли можно собрать в небольшую таблицу:
| Компонент | За что отвечает |
| Django | Логика приложения |
| PostgreSQL | Постоянные данные |
| Gunicorn | Запуск Django как production web-приложения |
| systemd | Управление процессом Gunicorn и автозапуск |
Nginx | Внешний HTTP/HTTPS-трафик, proxy, static и media |
Если смотреть на компоненты по отдельности, стек кажется большим.
Но стоит разложить обязанности — всё становится куда понятнее.
Как будет выглядеть итоговая схема
Теперь соберем все части в одну цепочку.
Пользователь открывает: https://example.com
Запрос приходит в Nginx.
Если это обычный запрос к приложению, Nginx передает его Gunicorn:

Django выполняет нужную логику, при необходимости читает или изменяет данные в PostgreSQL и возвращает ответ обратно по той же цепочке.
Но со static и media путь будет немного другим.
Например, запрос к: /static/css/style.css необязательно вообще отправлять в Django.
Nginx сможет отдать такой файл самостоятельно:

То же самое позже настроим для пользовательских media.
А над процессом Gunicorn будет стоять systemd:

Именно к такой архитектуре мы будем двигаться постепенно.
Не будем устанавливать все компоненты одной огромной простыней команд. Сначала подготовим Ubuntu, затем перенесем проект, изолируем Python-зависимости, подключим PostgreSQL и только после этого начнем собирать production-цепочку вокруг Django.
А первый практический шаг самый простой — подготовить сам VPS к развертыванию.
Подготавливаем Ubuntu к развертыванию
Для этого руководства я снова использую VPS от ServerMall. Просто потому, что уже привык с ними работать. У него и интерфейс знакомый, и серверы разворачиваются быстро.
Однако подойдет практически любой VPS, если на нем есть Ubuntu, публичный IP и возможность подключиться по SSH с правами sudo. Пользуйтесь провайдером к которому привыкли, а если такого нет – можете либо попробовать ServerMall, либо выбрать любого другого крупного.
Вернёмся к теме. Наша отправная точка выглядит так:

А дальше уже будем постепенно превращать пустой сервер в production-окружение для Django.
Проверяем систему и обновляем пакеты
Первым делом посмотрим, с какой системой вообще работаем:
cat /etc/os-release
В выводе будут версия Ubuntu, ее кодовое имя и другая базовая информация.
Например:
NAME="Ubuntu"
VERSION="24.04 LTS (Noble Numbat)"
VERSION_ID="24.04"
Заодно можно проверить архитектуру: dpkg --print-architecture
На большинстве обычных VPS увидим: amd64
Теперь обновим индекс пакетов: sudo apt update
APT получит свежую информацию о доступных пакетах из подключенных репозиториев Ubuntu.
Если на сервере давно ничего не обновлялось, можно заодно установить доступные обновления: sudo apt upgrade -y
Почему лучше сделать это сейчас?
Потому что дальше мы начнем ставить Python, PostgreSQL, Nginx и другие компоненты. Гораздо удобнее собирать окружение поверх актуальной системы, чем уже посреди развертывания внезапно обнаружить старые зависимости или давно ожидающие обновления.
После крупных системных обновлений иногда требуется reboot. Проверить это можно так:
test -f /var/run/reboot-required && echo "Reboot required"Если система действительно просит перезагрузку, лучше сделать это сейчас: sudo reboot , а затем снова подключиться по SSH.
Теперь сам фундамент готов.
Устанавливаем Python, pip, venv и системные зависимости
Django написан на Python, поэтому первым делом нам понадобится сам интерпретатор и инструменты вокруг него.
Установим базовый набор:
sudo apt install -y python3 python3-pip python3-venv python3-dev build-essential libpq-devЗдесь уже появляется несколько незнакомых пакетов, поэтому разберем, зачем они вообще нужны:
| Пакет | Для чего нужен |
| python3 | Сам интерпретатор Python |
| python3-pip | Установка Python-пакетов |
| python3-venv | Создание виртуальных окружений |
| python3-dev | Заголовочные файлы Python для сборки некоторых зависимостей |
| build-essential | Компилятор и базовые инструменты сборки |
| libpq-dev | Библиотеки разработки PostgreSQL, которые могут понадобиться Python-драйверу |
На первый взгляд python3-dev, build-essential и libpq-dev могут выглядеть лишними.
Но Python-пакеты бывают не только полностью написанными на Python.
Некоторые зависимости содержат части на C или должны собираться под конкретную систему во время установки. Если необходимых инструментов нет, обычный: pip install ... ,может внезапно закончиться ошибкой компиляции.
Поэтому удобнее подготовить сервер заранее.
Проверим версии:
python3 --version
pip3 --version
И убедимся, что модуль venv доступен: python3 -m venv --help
Если справка открывается, всё в порядке.
К самому виртуальному окружению мы еще вернемся отдельно. Пока достаточно понимать его смысл: мы не будем устанавливать все зависимости Django глобально в Ubuntu.
Позже создадим отдельную Python-среду именно для нашего проекта.
Примерно:

Так зависимости проекта меньше вмешиваются в системное окружение и другие Python-приложения.
Создаем каталог проекта

Теперь нужно решить, где будет жить код приложения.
Для production-проекта лучше заранее выбрать отдельный понятный каталог, а не складывать файлы куда придется.
Создадим, например: sudo mkdir -p /var/www/django-app
Передадим каталог текущему пользователю: sudo chown -R $USER:$USER /var/www/django-app
И перейдем внутрь: cd /var/www/django-app
Проверим: pwd
Ожидаем: /var/www/django-app
Почему именно /var/www?
Это не жесткое требование Django. Проект вполне может жить и в другом каталоге.
Но /var/www традиционно используется для файлов веб-проектов, поэтому структура сервера получается довольно очевидной:

Если через полгода придется снова зайти на сервер, вероятность спросить себя «куда я вообще засунул проект?» становится чуть ниже.
На этом этапе код мы еще не переносили.
Мы только подготовили место, куда он попадет в следующей главе.
Переносим Django-проект на сервер
На этом этапе наша задача довольно простая: положить код на VPS, проверить, что структура проекта выглядит ожидаемо, и подготовиться к созданию виртуального окружения.
Здесь важно не спешить с запуском. Сначала лучше убедиться, что на сервер действительно приехал весь проект, а не только половина файлов.
Куда складывать production-проект
В прошлой главе мы уже создали каталог: /var/www/django-app
Именно туда и будем помещать код.
Это не обязательное требование Django, а просто удобная организация файлов.
Можно представить сервер так:

Позже внутри этого же проекта появятся:

То есть все основные элементы приложения будут находиться в одном понятном месте.
Это особенно удобно, когда на VPS работает не один проект.
Например:

Каждый сервис получает свой отдельный каталог, и через несколько месяцев не приходится вспоминать, что именно лежит в /home/ubuntu/test_final_new_2.
Загружаем код на VPS
Способов перенести проект несколько.
1. Если код хранится в Git-репозитории, самый удобный вариант — клонировать его прямо на сервер.
Сначала установим Git, если его еще нет: sudo apt install -y git
Переходим в подготовленный каталог: cd /var/www/django-app
Если каталог пустой, проект можно клонировать сюда: git clone https://github.com/USER/PROJECT.git .
Точка в конце означает: «Поместить содержимое репозитория прямо в текущий каталог, а не создавать внутри еще одну вложенную папку.»
Без точки получилось бы примерно так: /var/www/django-app/PROJECT/
А с точкой:

2. Если используется приватный репозиторий, лучше не вставлять логин, пароль или access token прямо в команду.
Для production-сервера обычно используют SSH-ключ или deploy key.
Например: git clone git@github.com:USER/PROJECT.git .
3. Если Git вообще не используется, проект можно передать через scp, SFTP или другой привычный способ.
Например, с локального компьютера:
scp -r ./django-project/* ubuntu@203.0.113.10:/var/www/django-app/Какой способ выбрать — не принципиально.
Главное, чтобы в итоге на VPS оказался исходный код проекта, а не локальное виртуальное окружение, кэш Python или случайные секреты с компьютера разработчика.
Именно поэтому в Git обычно не кладут:
venv/
__pycache__/
.env
*.pyc
Эти вещи либо создаются заново на сервере, либо содержат данные, которые вообще не стоит хранить в репозитории.
Проверяем структуру проекта
Код загружен. Теперь не будем сразу выполнять pip install и migrate.
Сначала посмотрим, что вообще приехало: ls -la
Для типичного Django-проекта ожидаем увидеть что-то вроде:
manage.py
requirements.txt
project/
app/
Названия каталогов могут отличаться, но ключевой ориентир — файл: manage.py
Он обычно находится в корне Django-проекта и используется для административных команд:
python manage.py migrate
python manage.py collectstatic
python manage.py createsuperuser
Также должен быть файл со списком зависимостей.
Чаще всего: requirements.txt
Проверить его можно так: cat requirements.txt
Внутри могут находиться, например:
- Django
- gunicorn
- psycopg
- python-dotenv
А также другие библиотеки конкретного проекта.
Теперь заглянем в каталог самого Django-проекта.
Например: ls project/
Там обычно находятся:
- settings.py
- urls.py
- wsgi.py
- asgi.py
Особенно нас позже заинтересует: wsgi.py
Именно через него Gunicorn будет подключаться к Django-приложению.
На этом этапе полезно заранее понять имя Django-проекта.
Например, если структура такая:

В таком случае позже запуск Gunicorn будет ссылаться именно на: myproject.wsgi
И это мелочь, которая часто становится причиной ошибок вида: ModuleNotFoundError, если в конфигурации просто скопировали чужое имя проекта.
Создаем виртуальное окружение Python
Код проекта уже на сервере. Следующий шаг — подготовить для него отдельное Python-окружение.
Это важный момент, потому что production-сервер со временем редко остается местом только для одного приложения. Сегодня здесь один Django-проект, завтра может появиться второй, а вместе с ним — другой набор библиотек и другие версии зависимостей. Глазом моргнуть не успеете, как там образовалась своя экосистема.
Если всё устанавливать глобально в системный Python, начинается знакомая история:
- Проект A → Django 5.x
- Проект B → другая версия Django
- Проект C → свой набор библиотек
И все они пытаются жить в одном общем окружении.
Чтобы не превращать сервер в коммунальную квартиру для Python-пакетов, используют venv.
Зачем Django отдельный venv
Venv — это виртуальное окружение Python.
Оно создаёт отдельный каталог, внутри которого находятся собственные:
- Python-пакеты;
- Исполняемые файлы;
- Зависимости проекта;
- Установленный Django;
- Gunicorn;
- Драйвер PostgreSQL;
- Остальные библиотеки из requirements.txt.
Важно понимать: venv — это не виртуальная машина и не контейнер.
Он не изолирует сеть, процессы или файловую систему целиком.
Он просто отделяет Python-зависимости одного проекта от системного Python и других проектов:

В результате обновление библиотеки в одном проекте не должно неожиданно ломать другой.
Это особенно удобно в production: окружение проекта становится более предсказуемым и воспроизводимым.
В качестве альтернативы можно и упаковать приложение в контейнер, но в рамках профессионального интереса мы будем использовать "классическую" установку с виртуальным окружением.
Создаем и активируем окружение
Перейдем в каталог проекта: cd /var/www/django-app
Теперь создадим виртуальное окружение: python3 -m venv venv
После этого внутри проекта появится каталог: /var/www/django-app/venv/
В нем и будут жить Python и пакеты именно этого проекта.
Активируем окружение: source venv/bin/activate
После активации приглашение терминала обычно меняется примерно так: (venv) ubuntu@server:/var/www/django-app$
Это удобная визуальная подсказка: сейчас команды python и pip относятся уже к нашему виртуальному окружению.
Проверим:
which python
which pip
Ожидаем пути примерно такого вида:
/var/www/django-app/venv/bin/python
/var/www/django-app/venv/bin/pip
То есть теперь мы работаем не с системным Python, а с отдельной копией окружения проекта.
Выйти из него позже можно командой: deactivate
Но пока оставляем venv активным.
Устанавливаем зависимости проекта
Перед установкой пакетов полезно обновить сам pip внутри виртуального окружения: python -m pip install --upgrade pip
Теперь устанавливаем зависимости проекта: pip install -r requirements.txt
Pip прочитает файл requirements.txt и установит указанные там библиотеки именно в текущий venv.
Например, внутри могут быть:
- Django
- gunicorn
- psycopg
- python-dotenv
Или более строго зафиксированные версии:
- Django==5.2.6
- gunicorn==23.0.0
- psycopg==3.2.9
Фиксация версий делает развертывание более предсказуемым.
Без неё команда: pip install -r requirements.txt
сегодня и через полгода теоретически может установить разные релизы библиотек.
Для production это не всегда желательно.
Если в requirements.txt по какой-то причине нет Gunicorn, его можно добавить отдельно: pip install gunicorn
А для работы с PostgreSQL нужен совместимый драйвер, например: pip install psycopg
Но лучше, чтобы все необходимые зависимости были описаны в requirements.txt, а не устанавливались вручную «по памяти».
Иначе сервер начинает работать, а через месяц никто уже не помнит, какие дополнительные пакеты туда ставили.
Проверяем Django и Gunicorn

После установки не будем сразу переходить к PostgreSQL.
Сначала убедимся, что нужные компоненты действительно появились внутри venv.
Проверим Django: python -m django --version
В ответ должна появиться установленная версия.
Теперь Gunicorn: gunicorn --version
И сам список пакетов: pip list
Для более точной проверки можно убедиться, откуда запускается Gunicorn: which gunicorn
Ожидаем: /var/www/django-app/venv/bin/gunicorn
Это важная мелочь.
Если команда вдруг показывает что-то вроде: /usr/bin/gunicorn, значит мы, скорее всего, используем системную установку, а не версию из виртуального окружения.
В production потом это может привести к довольно забавной ситуации: вручную проект запускается одной версией Python и библиотек, а systemd — совсем другой.
Поэтому сейчас лучше один раз убедиться, что весь стек действительно находится внутри нужного venv.
Поднимаем PostgreSQL для Django
Теперь пора подключить то, без чего большинство реальных Django-проектов долго не живут, — полноценную базу данных.
По умолчанию новый Django-проект обычно стартует с SQLite. Для локальной разработки это удобно: отдельный сервер базы не нужен, всё хранится в одном файле, а начать можно буквально за минуту.
Но production — уже другая история.
Почему production лучше не оставлять на SQLite
SQLite хранит всю базу в одном файле.
Условно: db.sqlite3
Для локальной разработки это даже плюс.
Проект можно быстро запустить, перенести, удалить и создать заново без отдельной настройки СУБД.
Но когда появляются реальные пользователи, параллельные запросы, миграции и постоянная нагрузка, такой подход начинает ограничивать.
PostgreSQL лучше подходит для production-сценариев, потому что это полноценная серверная СУБД.
Она умеет нормально работать с:
- Несколькими одновременными подключениями;
- Транзакциями;
- Блокировками;
- Сложными запросами;
- Индексами;
- Большими объемами данных;
- Отдельными пользователями и правами доступа.
Проще говоря:
- SQLite → удобно начать
- PostgreSQL → удобнее жить дальше в production
Это не значит, что SQLite плохая база.
У нее просто другая зона комфорта.
Если Django-проект — это небольшой внутренний инструмент с минимальной нагрузкой, SQLite может прекрасно справляться и дальше.
Но для публичного production-приложения PostgreSQL обычно дает гораздо больше пространства для роста.
Получится так:

Причем Django не будет подключаться к базе под административным пользователем PostgreSQL.
Как и в прошлой статье с MySQL, создадим отдельную базу и отдельную учетную запись именно для приложения.
Устанавливаем PostgreSQL
PostgreSQL доступен в стандартных репозиториях Ubuntu.
Установим сервер и дополнительные утилиты:
sudo apt update
sudo apt install -y postgresql postgresql-contrib
После установки PostgreSQL обычно автоматически запускается через systemd.
Проверим: sudo systemctl status postgresql --no-pager
Нас интересует Active: active (exited) или состояние работающего PostgreSQL-кластера, в зависимости от версии и организации systemd units.
Дополнительно можно проверить готовность самого сервера: sudo -u postgres pg_isready
Ожидаем ответ примерно такого вида: /var/run/postgresql:5432 - accepting connections
Эта проверка говорит, что PostgreSQL действительно принимает подключения.
Посмотрим, какой порт используется: sudo ss -lntp | grep 5432
По умолчанию PostgreSQL работает на: 5432
Но наружу этот порт нам сейчас вообще не нужен.
Django и PostgreSQL находятся на одном VPS, поэтому база может спокойно оставаться доступной только локально.
Создаем базу и отдельного пользователя
После установки PostgreSQL в системе появляется административная учетная запись: postgres
Это и Linux-пользователь для обслуживания PostgreSQL, и одноименная административная роль внутри самой СУБД.
Для повседневной работы Django использовать ее не будем.
Зайдем в PostgreSQL: sudo -u postgres psql
Приглашение изменится примерно на: postgres=#
Теперь создадим базу для нашего проекта: CREATE DATABASE django_db;
И отдельного пользователя: CREATE USER django_user WITH PASSWORD 'STRONG_PASSWORD';
Схематически это выглядит так:

Пользователь postgres остается администратору, а приложение получает собственную учетную запись.
Это полезно по той же причине, по которой мы не запускали приложение от MySQL root: если приложение однажды будет скомпрометировано, его учетная запись не должна автоматически получать полный контроль над всеми базами PostgreSQL на сервере.
Выдаем права только на нужную базу
Теперь дадим django_user доступ к нашей базе: GRANT ALL PRIVILEGES ON DATABASE django_db TO django_user;
Но здесь есть нюанс PostgreSQL.
Права на саму базу и права на объекты внутри нее — не всегда одно и то же.
Django во время миграций должен создавать таблицы, индексы и другие объекты.
Поэтому подключимся к нужной базе: \c django_db
И дадим пользователю возможность работать со схемой public: GRANT ALL ON SCHEMA public TO django_user;
В современных конфигурациях PostgreSQL это особенно важно, потому что обычному пользователю может не хватать прав на создание объектов в public.
Теперь Django сможет выполнять миграции и создавать свои таблицы.
При желании можно сделать пользователя владельцем базы: ALTER DATABASE django_db OWNER TO django_user;
Для отдельной базы одного приложения это часто удобно: django_user становится владельцем именно своей базы, но не получает административных прав на весь PostgreSQL Server.
После этого выйдем: \q
Главная мысль здесь такая:
- Postgres → администрирует сервер PostgreSQL
- Django_user → работает только с django_db
И роли больше не смешиваются.
Проверяем подключение

Теперь проверим учетную запись еще до того, как начнем настраивать Django.
Подключимся локально: psql -h 127.0.0.1 -U django_user -d django_db
PostgreSQL попросит пароль.
Если всё настроено правильно, приглашение станет примерно таким: django_db=>
Проверим текущего пользователя: SELECT current_user;
Ожидаем: django_user
А текущую базу: SELECT current_database();
Получим: django_db
Для окончательной проверки можно временно создать небольшую таблицу:
CREATE TABLE connection_test (
id SERIAL PRIMARY KEY,
message TEXT NOT NULL
);
Добавим запись:
INSERT INTO connection_test (message)
VALUES ('PostgreSQL works');
И прочитаем: SELECT * FROM connection_test;
Если данные возвращаются, значит пользователь действительно может создавать таблицы и работать с базой.
После проверки тестовую таблицу можно удалить: DROP TABLE connection_test;
Теперь база полностью готова со своей стороны.
Но Django пока ничего о ней не знает. Более того, нам точно не хочется вписывать пароль PostgreSQL, SECRET_KEY и production-настройки прямо в settings.py, а затем случайно отправить всё это в Git.
Поэтому следующим шагом вынесем секреты и параметры окружения из кода и уже через них подключим Django к PostgreSQL.
Убираем секреты из settings.py
Почему SECRET_KEY и пароль базы нельзя хранить прямо в коде
В settings.py Django хранит критически важные параметры проекта.
Например:
SECRET_KEY = "some-secret-value"
DEBUG = True
и конфигурацию базы:
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": "django_db",
"USER": "django_user",
"PASSWORD": "STRONG_PASSWORD",
"HOST": "127.0.0.1",
"PORT": "5432",
}
}
Технически Django прекрасно заработает.
Проблема начинается позже.
Допустим, проект хранится в Git.
Вы выполняете:
git add .
git commit -m "production settings"
git push
и вместе с кодом в репозиторий уезжает пароль PostgreSQL.
Если репозиторий публичный — ситуация очевидно плохая.
Но даже в приватном репозитории секреты лучше не смешивать с кодом. Доступ к Git могут получать разработчики, CI/CD, сторонние сервисы и другие системы, которым совершенно не обязательно знать пароль production-базы.
С SECRET_KEY история похожая.
Django использует его для криптографических операций внутри framework. Поэтому это не декоративная строка, которую можно спокойно публиковать вместе с кодом.
Удобнее разделить:

Тогда один и тот же код можно использовать на разных стендах:
Development
→ своя база
→ DEBUG=True
Staging
→ другая база
→ свои домены
Production
→ production PostgreSQL
→ DEBUG=False
→ реальный домен
Код один, а значения меняются в зависимости от окружения.
Именно для этого мы создадим .env.
Создаем .env
Перейдем в каталог проекта: cd /var/www/django-app
Создадим файл: nano .env
Добавим туда значения для нашего production-окружения:
SECRET_KEY=CHANGE_ME_TO_A_LONG_RANDOM_VALUE
DEBUG=False
DB_NAME=django_db
DB_USER=django_user
DB_PASSWORD=STRONG_PASSWORD
DB_HOST=127.0.0.1
DB_PORT=5432
ALLOWED_HOSTS=example.com,www.example.com
Теперь секреты находятся отдельно от settings.py.
Но сам файл .env тоже нужно защищать.
Во-первых, его не стоит отправлять в Git.
Проверим .gitignore: nano .gitignore
И добавим: .env
Если .env уже когда-то был добавлен в репозиторий, одного .gitignore недостаточно: Git продолжит отслеживать файл, пока его отдельно не уберут из индекса.
Во-вторых, ограничим права на самом сервере: chmod 600 .env
Теперь читать и изменять файл сможет только его владелец.
Передаем параметры PostgreSQL через переменные окружения
Теперь нужно научить Django читать эти значения.
Способ зависит от проекта.
Если в requirements.txt уже используется, например, python-dotenv, можно загрузить .env через него.
В начале settings.py:
import os
from pathlib import Path
from dotenv import load_dotenv
BASE_DIR = Path(__file__).resolve().parent.parent
load_dotenv(BASE_DIR / ".env")
После этого получаем значения через:
os.getenv("VARIABLE_NAME")
Например: SECRET_KEY = os.getenv("SECRET_KEY")
Теперь настроим PostgreSQL:
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": os.getenv("DB_NAME"),
"USER": os.getenv("DB_USER"),
"PASSWORD": os.getenv("DB_PASSWORD"),
"HOST": os.getenv("DB_HOST", "127.0.0.1"),
"PORT": os.getenv("DB_PORT", "5432"),
}
}
Обратите внимание на: os.getenv("DB_HOST", "127.0.0.1")
Второе значение — это fallback.
Если переменная DB_HOST отсутствует, Django использует: 127.0.0.1
То же самое мы сделали для PostgreSQL-порта.
В итоге settings.py больше не знает реальный пароль.
Он знает только: «Возьми пароль из окружения».
А конкретное значение находится уже на сервере.
Настраиваем DEBUG и ALLOWED_HOSTS
Теперь разберемся еще с двумя production-настройками.
Первая: DEBUG
На локальной машине обычно используется: DEBUG = True
Это удобно: Django показывает подробные страницы ошибок с traceback, переменными и другой информацией для разработчика.
В production такую информацию посетителю показывать не нужно.
Поэтому в .env мы написали: DEBUG=False
Но здесь есть маленькая ловушка.
Если сделать: DEBUG = os.getenv("DEBUG")
значение: False
придет как строка, а непустая строка в Python считается истинным значением.
То есть можно написать False и неожиданно получить поведение, похожее на включенный флаг.
Поэтому лучше преобразовать значение явно: DEBUG = os.getenv("DEBUG", "False").lower() == "true"
Теперь:
True → True
true → True
False → False
Следующий параметр: ALLOWED_HOSTS
Django использует его, чтобы понимать, для каких host-имен приложение вообще должно обслуживать запросы.
В .env: ALLOWED_HOSTS=example.com,www.example.com
А в settings.py:
ALLOWED_HOSTS = [
host.strip()
for host in os.getenv("ALLOWED_HOSTS", "").split(",")
if host.strip()
]
В итоге строка: example.com,www.example.com
превращается в: ["example.com", "www.example.com"]
Пока реальный домен еще может быть не подключен, для временной проверки можно использовать нужный host отдельно.
Но вариант: ALLOWED_HOSTS = ["*"], лучше не оставлять как постоянную production-настройку просто ради того, чтобы «всё заработало».
Мы ещё вернемся к ALLOWED_HOSTS, когда подключим домен.
Проверяем, что Django видит окружение
Теперь важно не просто сохранить файлы, а убедиться, что Django действительно получил значения.
У нас должен быть активен venv:
cd /var/www/django-app
source venv/bin/activate
Сначала можно проверить саму конфигурацию Django: python manage.py check
Если всё в порядке, ожидаем: System check identified no issues
Теперь проверим подключение к PostgreSQL через сам Django.
Откроем shell: python manage.py shell
И выполним:
from django.db import connection
connection.ensure_connection()
print(connection.settings_dict["NAME"])
Если подключение установилось, увидим: django_db
После этого: exit()
Есть и более простой практический тест, который мы всё равно скоро выполним: python manage.py migrate
Если Django успешно подключается к PostgreSQL и начинает работать с миграциями, значит параметры базы читаются корректно.
Но миграции мы оставим для следующей главы.
Сейчас главное проверить три вещи:

Есть еще один важный момент.
Мы защитили секреты от Git, но позже Gunicorn будет запускаться не из нашей текущей shell-сессии, а через systemd.
А значит, systemd тоже должен знать, откуда брать эти переменные.
К этому вернемся, когда будем создавать gunicorn.service.
Пока же Django уже знает свою production-базу и основные настройки окружения.
Следующий шаг — подготовить само приложение к первому production-запуску: выполнить миграции, собрать static и проверить проект перед подключением Gunicorn.
Подготавливаем Django к production-запуску
Как и писал ранее, здесь нас ждёт три обязательных шага:
- Применить миграции;
- Собрать static-файлы;
- Проверить, что сам Django запускается без ошибок.
При необходимости заодно создадим административного пользователя.
Запускаем миграции
Начнем с базы.
Django хранит структуру моделей в коде, но одной записи класса в models.py недостаточно, чтобы соответствующая таблица появилась в PostgreSQL.
Для этого существуют миграции.
Примерно:

Если проект уже содержит готовые миграции, нам нужно просто применить их к production-базе.
Активируем виртуальное окружение:
cd /var/www/django-app
source venv/bin/activate
Проверим, какие миграции Django видит: python manage.py showmigrations
Не примененные миграции будут отмечены примерно так: [ ]
А примененные: [X]
Теперь запускаем: python manage.py migrate
Django подключится к PostgreSQL через настройки, которые мы задали в .env, и создаст необходимые таблицы.
В успешном выводе появятся строки вроде:
Applying contenttypes.0001_initial... OK
Applying auth.0001_initial... OK
Applying sessions.0001_initial... OK
Если миграции заканчиваются ошибкой подключения к PostgreSQL, возвращаемся к предыдущей главе и проверяем DB_NAME, DB_USER, пароль, host и права пользователя.
Если же видим OK, значит связка:

уже работает на практике, а не только в конфигурации.
Создаем superuser при необходимости
Если проект использует стандартную административную панель Django, можно сразу создать администратора.
Для этого: python manage.py createsuperuser
Django попросит указать имя пользователя, email и пароль.
После этого учетная запись сможет входить в /admin/, когда сайт уже будет доступен через Nginx.
Создавать superuser обязательно не всегда.
Если проект вообще не использует Django Admin или административные аккаунты создаются другим способом, этот шаг можно спокойно пропустить.
И здесь есть небольшая, но важная мысль: superuser внутри Django — это не Linux root и не пользователь PostgreSQL.
Это отдельный уровень доступа уже внутри самого веб-приложения.
То есть у нас постепенно появляется несколько разных ролей:
Linux user
→ управляет файлами и процессами на VPS
PostgreSQL user
→ работает с базой
Django superuser
→ управляет данными через Django Admin
Названия и права у них разные, и смешивать эти уровни не стоит.
Собираем static через collectstatic
Следующий шаг часто вызывает вопросы у тех, кто впервые переносит Django в production.
В проекте могут быть CSS, JavaScript, изображения административной панели и другие static-файлы.
Во время разработки Django умеет удобно работать с ними сам.
В production мы пойдем другим путем: соберем static в отдельный каталог, а позже Nginx будет раздавать их напрямую.
Сначала убедимся, что в settings.py задан STATIC_ROOT.
Например:
STATIC_URL = "/static/"
STATIC_ROOT = BASE_DIR / "staticfiles"
Теперь выполним: python manage.py collectstatic
Django найдет static-файлы всех приложений и сложит их в один каталог: /var/www/django-app/staticfiles/
По смыслу происходит так:

Если Django попросит подтверждение перезаписи файлов, для автоматического production-развертывания можно использовать: python manage.py collectstatic --noinput
После выполнения можно проверить каталог: ls -lah staticfiles/
Важно понимать: collectstatic не создает CSS или JavaScript из воздуха.
Он собирает уже существующие static-файлы проекта и установленных Django-приложений в одно место, откуда их удобно отдавать веб-серверу.
К media это не относится.
Пользовательские загрузки вроде аватаров, документов и фотографий позже будут храниться отдельно.
Проверяем приложение локально

Теперь перед Gunicorn проведем последнюю проверку самого Django.
Сначала: python manage.py check
Ожидаемый результат: System check identified no issues
Эта команда проверяет конфигурацию проекта и помогает поймать часть проблем еще до запуска.
Но можно пойти чуть дальше и временно поднять встроенный сервер Django локально: python manage.py runserver 127.0.0.1:8000
Обратите внимание: мы специально используем: 127.0.0.1, а не: 0.0.0.0
Сейчас нам не нужно публиковать development server в интернет.
Мы просто хотим убедиться, что проект действительно стартует на самом VPS.
В другой SSH-сессии проверим: curl http://127.0.0.1:8000
Если приходит HTML приложения или ожидаемый HTTP-ответ, значит Django способен запуститься с текущими настройками.
После проверки остановим runserver: Ctrl + C
И больше к нему в production возвращаться не будем.
На этом сам Django подготовлен:

Но пока приложение всё еще стартует через development server вручную.
Следующий шаг — заменить его Gunicorn и посмотреть, как Django будет работать уже через production WSGI-сервер.
Запускаем Django через Gunicorn
Теперь между Nginx и самим Django появится Gunicorn — отдельный WSGI-сервер, который будет запускать приложение и принимать запросы от веб-сервера.
Почему runserver не используют в production
Команда python manage.py runserver создана прежде всего для разработки.
Она удобна, потому что:
- Быстро запускает проект;
- Показывает ошибки;
- Автоматически перезапускается при изменениях кода;
- Не требует отдельной настройки.
Но в production от сервера ждут уже другого поведения.
Нам важно, чтобы приложение:
- Стабильно обрабатывало реальные запросы;
- Могло использовать несколько worker-процессов;
- Управлялось отдельным системным сервисом;
- Нормально работало за reverse proxy;
- Не зависело от открытого терминала.
Поэтому runserver здесь заканчивает свою работу.
Как Gunicorn связан с Django
Как говорили ранее, Gunicorn — это WSGI HTTP Server для Python-приложений.
Но особо внимательные заметили, что здесь появляется еще одна аббревиатура — WSGI.
Если максимально упростить, WSGI — это стандарт взаимодействия между Python web-приложением и сервером, который его запускает.
То есть Gunicorn не заменяет Django.
Он предоставляет среду, в которой Django может принимать HTTP-запросы.
Условно:

Внутри обычного Django-проекта уже существует файл: myproject/wsgi.py
Мы проверяли его наличие еще после загрузки проекта на VPS.
Обычно внутри находится примерно такая конфигурация:
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault(
"DJANGO_SETTINGS_MODULE",
"myproject.settings"
)
application = get_wsgi_application()
Нас особенно интересует объект: application
Именно к нему затем подключается Gunicorn.
Поэтому команда запуска обычно заканчивается конструкцией: myproject.wsgi:application
Здесь:
- myproject.wsgi — Python-модуль;
- application — WSGI-приложение внутри него.
Если каталог вашего Django-проекта называется иначе, это имя тоже нужно изменить.
Например: shop/wsgi.py означает: shop.wsgi:application
Копировать чужое имя проекта вслепую здесь определенно не стоит.
Проверяем ручной запуск Gunicorn
Gunicorn мы уже установили вместе с Python-зависимостями.
Активируем виртуальное окружение:
cd /var/www/django-app
source venv/bin/activate
И для начала убедимся, что запускается именно версия из нашего venv: which gunicorn
Ожидаем примерно: /var/www/django-app/venv/bin/gunicorn
Теперь запустим Django вручную: gunicorn --bind 127.0.0.1:8000 myproject.wsgi:application
Если имя проекта другое, заменяем: myproject на свое.
Например: gunicorn --bind 127.0.0.1:8000 shop.wsgi:application
После запуска Gunicorn покажет сообщения примерно такого смысла:
Starting gunicorn
Listening at: http://127.0.0.1:8000
Using worker
Booting worker
Теперь приложение уже запущено не через manage.py runserver, а через Gunicorn.
Откроем вторую SSH-сессию и проверим: curl http://127.0.0.1:8000
Если приходит HTML страницы или ожидаемый ответ приложения, значит цепочка работает:

Можно также посмотреть только HTTP-заголовки: curl -I http://127.0.0.1:8000
Например, нас устроит: HTTP/1.1 200 OK
или другой ожидаемый код конкретного маршрута.
После проверки остановим ручной запуск: Ctrl + C
Пока Gunicorn всё еще зависит от нашей SSH-сессии.
Чуть позже это исправит systemd.
Выбираем Unix socket или локальный TCP-порт
Теперь нужно решить, как Nginx позже будет связываться с Gunicorn.
Есть два распространенных варианта.
Первый — локальный TCP-порт:

Второй — Unix socket:

И оба варианта рабочие.
Начнём с первого. Локальный TCP-порт.
Например: gunicorn --bind 127.0.0.1:8000 myproject.wsgi:application
Главное здесь — именно: 127.0.0.1
Gunicorn слушает только loopback-интерфейс сервера.
То есть пользователь из интернета не сможет напрямую обратиться к: SERVER_IP:8000
Запросы будут идти через Nginx.
Это дает простую и понятную схему:

Для учебного развертывания это особенно удобно: порт легко проверить через curl, ss и другие стандартные инструменты.
Вторым идёт Unix socket.
Вместо TCP Gunicorn может создать специальный файл-сокет.
Например: /run/gunicorn/django-app.sock
Тогда Nginx и Gunicorn общаются через него внутри одной Linux-системы.
По итогу выйдет так:

Здесь вообще не используется TCP-порт между двумя локальными процессами.
Unix socket вполне привычен для подобных production-конфигураций, но появляется дополнительный слой работы с правами доступа:
- Кто владеет socket?
- Может ли Nginx его открыть?
- Существует ли каталог?
- Какие у него permissions?
И если где-то ошибиться, вместо сайта можно получить 502 Bad Gateway, хотя сам Django при этом совершенно здоров.
Поэтому в этом руководстве выберем более наглядный вариант: 127.0.0.1:8000
Для нашей схемы его вполне достаточно.
Gunicorn не торчит наружу, а Nginx позже сможет обращаться к нему через: proxy_pass http://127.0.0.1:8000;
При этом полезно помнить простое правило:
- 127.0.0.1:8000 → доступен только самому VPS
- 0.0.0.0:8000 → слушает все IPv4-интерфейсы
Поэтому без необходимости использовать: --bind 0.0.0.0:8000, в production-схеме за Nginx нам не нужно.
Теперь мы уже доказали, что приложение работает через production WSGI-сервер.
Но ручной запуск пока остается ручным: закроем SSH, перезагрузим VPS — и Gunicorn исчезнет.
Следующим шагом передадим управление процессом systemd, чтобы приложение запускалось автоматически и вело себя как нормальный системный сервис.
Делаем Gunicorn системным сервисом
Зачем Gunicorn нужен systemd
Systemd — это система управления службами в Linux.
Она уже управляет многими компонентами нашего сервера.
Например:
- PostgreSQL
- Nginx
- SSH
Работать с Gunicorn вручную каждый раз было бы странно, поэтому сделаем из него такой же нормальный системный сервис.
В таком случае systemd сможет:
- Запускать Gunicorn при старте VPS;
- Останавливать и перезапускать сервис;
- Показывать его состояние;
- Хранить логи;
- При необходимости автоматически перезапускать процесс после сбоя.
То есть вместо команды:
gunicorn --bind 127.0.0.1:8000 myproject.wsgi:application
мы позже будем использовать:
sudo systemctl start gunicorn
sudo systemctl restart gunicorn
sudo systemctl status gunicorn
С точки зрения эксплуатации это гораздо удобнее.
Создаем gunicorn.service
Файлы пользовательских systemd-сервисов обычно размещают в: /etc/systemd/system/
Создадим новый unit: sudo nano /etc/systemd/system/gunicorn.service
Добавим:
[Unit]
Description=Gunicorn for Django application
After=network.target postgresql.service
[Service]
User=ubuntu
Group=www-data
WorkingDirectory=/var/www/django-app
ExecStart=/var/www/django-app/venv/bin/gunicorn \
--workers 3 \
--bind 127.0.0.1:8000 \
myproject.wsgi:application
Restart=on-failure
[Install]
WantedBy=multi-user.target
Здесь уже много новых параметров, поэтому разберем их подробнее.
Указываем рабочий каталог и окружение
Начнем с блока:
[Unit]
Description=Gunicorn for Django application
After=network.target postgresql.service
Description — просто понятное описание сервиса.
After=network.target postgresql.service — говорит systemd запускать Gunicorn после того, как базовые сетевые службы и PostgreSQL уже поднялись.
Это не означает, что systemd магически проверит работоспособность самой базы, но порядок старта становится логичнее.
Теперь блок: [Service]
Здесь находится основная конфигурация процесса.
User
User=ubuntu
Gunicorn будет работать от обычного пользователя, а не от root.
И это правильнее с точки зрения безопасности.
Если приложение скомпрометируют, процесс не должен автоматически получать полный административный доступ ко всему серверу.
Разумеется, если ваш пользователь называется иначе, нужно указать реальное имя. Оптимально будет завести отдельного пользователя для этого.
Проверить его можно так: whoami
Group
Group=www-data
www-data обычно используется Nginx и другими веб-сервисами в Ubuntu.
В нашей конфигурации Nginx будет обращаться к Gunicorn через локальный TCP-порт, поэтому прямой общий доступ к Unix socket нам не требуется.
Тем не менее такая группа остается вполне обычным вариантом для веб-приложения.
WorkingDirectory
WorkingDirectory=/var/www/django-app
Это рабочий каталог процесса.
Отсюда Gunicorn сможет корректно импортировать myproject.wsgi и находить файлы проекта.
Если забыть WorkingDirectory, systemd может запустить команду из другого каталога, после чего появляются ошибки вроде: ModuleNotFoundError: No module named 'myproject'
Хотя сам проект при этом лежит на сервере и прекрасно запускается вручную.
ExecStart
Самая важная строка:
ExecStart=/var/www/django-app/venv/bin/gunicorn \
--workers 3 \
--bind 127.0.0.1:8000 \
myproject.wsgi:application
Обратите внимание: здесь мы не пишем просто: gunicorn
Мы указываем полный путь: /var/www/django-app/venv/bin/gunicorn
Это гарантирует, что systemd запускает Gunicorn именно из виртуального окружения нашего проекта.
Иначе можно получить неприятную ситуацию:
в SSH
→ используется Gunicorn из venv
→ приложение работает
Через systemd
→ используется другой Python/Gunicorn
→ зависимости не найдены
Теперь: --workers 3 задает количество worker-процессов.
Worker — это отдельный процесс Gunicorn, который может обрабатывать запросы.
Схематично:

Три worker здесь — просто понятный стартовый пример, а не универсальное число для любого VPS.
Оптимальное количество зависит от:
- Числа CPU;
- Характера нагрузки;
- Объема памяти;
- Того, насколько приложение CPU- или I/O-зависимо.
Поэтому не стоит воспринимать 3 как магическую production-константу.
Дальше: --bind 127.0.0.1:8000 оставляет Gunicorn доступным только внутри VPS.
А: myproject.wsgi:application указывает на WSGI-приложение Django.
Если проект называется иначе, эту часть нужно заменить.
А что с .env?
Здесь есть важный момент.
В предыдущей главе мы использовали python-dotenv и загрузили файл: load_dotenv(BASE_DIR / ".env") прямо внутри settings.py.
Поэтому при запуске через Gunicorn Django сам прочитает: /var/www/django-app/.env
Но при условии, что:
- Файл существует;
- BASE_DIR определен корректно;
- Пользователь ubuntu, от которого запускается Gunicorn, имеет право его читать.
Проверим права: ls -l /var/www/django-app/.env
Мы ранее задавали: chmod 600 .env
Если владельцем остается тот же ubuntu, всё нормально.
Получается такая цепочка:

То есть дополнительно прописывать пароль PostgreSQL прямо в gunicorn.service нам не нужно.
И это хорошо: секреты снова не размазываются по нескольким конфигурационным файлам.
Запускаем сервис и включаем автозапуск
Сохраняем unit-файл.
После создания или изменения systemd-конфигурации нужно перечитать units: sudo systemctl daemon-reload
Теперь запускаем Gunicorn: sudo systemctl start gunicorn
Проверяем: sudo systemctl status gunicorn --no-pager
Если всё хорошо, увидим состояние: Active: active (running)
Теперь включим автозапуск: sudo systemctl enable gunicorn
Можно сразу объединить запуск и включение: sudo systemctl enable --now gunicorn
Разница простая:
- start → запускает сейчас
- enable → запускает автоматически после загрузки системы
- enable --now → делает оба действия
Теперь даже после перезагрузки VPS systemd сможет поднять Gunicorn снова.
Проверить, включен ли автозапуск: sudo systemctl is-enabled gunicorn
Ожидаем: enabled
Проверяем статус и логи

Самый первый инструмент диагностики: sudo systemctl status gunicorn --no-pager
Но статус показывает только часть информации.
Если Gunicorn не запускается, смотрим журнал: sudo journalctl -u gunicorn
Последние записи: sudo journalctl -u gunicorn -n 50 --no-pager
А если хотим наблюдать журнал в реальном времени: sudo journalctl -u gunicorn -f
Это особенно полезно при запуске приложения и поиске ошибок импорта, подключения к PostgreSQL или конфигурации Django.
Например, если ошиблись в myproject.wsgi:application в журнале может появиться: ModuleNotFoundError
Если Gunicorn не может прочитать .env, Django может сообщить об отсутствующем SECRET_KEY или настройках базы.
Если порт уже занят: 127.0.0.1:8000, увидим ошибку bind.
Проверить сам порт можно отдельно: sudo ss -lntp | grep 8000
При работающем Gunicorn ожидаем что-то на: 127.0.0.1:8000
И последняя практическая проверка: curl http://127.0.0.1:8000
Если приложение отвечает, значит теперь оно работает уже не из нашего терминала, а как полноценная системная служба.
Можно даже закрыть SSH-сессию, подключиться заново и повторить: sudo systemctl status gunicorn --no-pager
Gunicorn останется на месте.
Следующим слоем поставим Nginx — именно он станет публичной точкой входа в наше Django-приложение.
Ставим Nginx перед Django
Почему Gunicorn не стоит выставлять напрямую в интернет
Технически можно было бы запустить Gunicorn так: gunicorn --bind 0.0.0.0:8000 myproject.wsgi:application и открыть порт 8000 во внешнем firewall.
Сайт даже заработал бы по адресу вроде: http://203.0.113.10:8000
Но для production-схемы это такое себе.
Gunicorn нужен прежде всего для запуска Python-приложения. А внешнюю работу с HTTP-трафиком удобнее отдать специализированному веб-серверу.
Nginx лучше подходит для таких задач, как:
- Прием запросов на портах 80 и 443;
- Работа с доменами;
- TLS и HTTPS;
- Раздача static и media;
- Проксирование;
- Обработка HTTP-заголовков;
- Ограничения на размер запросов;
- Ведение access/error logs.
Поэтому Gunicorn оставляем внутри VPS: 127.0.0.1:8000, а пользователю вообще не нужно знать, что такой порт существует.
Это еще и упрощает архитектуру безопасности: наружу открываются только те сервисы, которые действительно должны быть публичными.
Создаем конфигурацию сайта
Установим Nginx:
sudo apt update
sudo apt install -y nginx
Проверим сервис: sudo systemctl status nginx --no-pager
Если всё в порядке, увидим: Active: active (running)
Заодно включим автозапуск, если он по какой-то причине еще не включен: sudo systemctl enable nginx
Теперь создадим отдельную конфигурацию для Django-проекта: sudo nano /etc/nginx/sites-available/django-app
Для начала она может выглядеть так:
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
location / {
proxy_pass http://127.0.0.1:8000;
}
}
Пока здесь всего несколько строк:
- listen 80 говорит Nginx принимать обычные HTTP-запросы.
- server_name example.com www.example.com; определяет, для какого домена предназначен этот server block.
Реальный домен подключим чуть позже, поэтому сейчас используется документационный пример: example.com
А внутри:
location / {
...
}
обрабатываются все обычные запросы к сайту.
Настраиваем проксирование на Gunicorn
Главная строка здесь: proxy_pass http://127.0.0.1:8000;
Она говорит Nginx: если запрос попал в этот location, передай его приложению, которое работает локально на порту 8000.
Это тот же адрес, который мы прописали в gunicorn.service: --bind 127.0.0.1:8000
Поэтому обе стороны должны совпадать.
Если Gunicorn слушает: 127.0.0.1:8000
а в Nginx случайно написать: proxy_pass http://127.0.0.1:9000;
Nginx не найдет приложение и, скорее всего, вернет: 502 Bad Gateway
Перед включением конфигурации полезно еще раз проверить Gunicorn: sudo ss -lntp | grep 8000 и curl http://127.0.0.1:8000
Если локально Django отвечает, значит backend для Nginx уже готов.
Передаем нужные HTTP-заголовки
Одного proxy_pass достаточно, чтобы получить базовое проксирование, но Django полезно передавать информацию об исходном запросе.
Расширим конфигурацию:
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
location / {
proxy_pass http://127.0.0.1:8000;
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_set_header Host $host; | Исходный host запроса | Django использует его в том числе при проверке ALLOWED_HOSTS. |
| proxy_set_header X-Real-IP $remote_addr; | IP-адрес клиента | Позволяет приложению и логам видеть адрес пользователя, а не только локальный адрес Nginx. |
| proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; | Цепочку прокси и исходный IP клиента | Полезно для логирования, аналитики и случаев, когда запрос проходит через несколько proxy-серверов. |
| proxy_set_header X-Forwarded-Proto $scheme; | Схему исходного запроса: http или https | Особенно важно после подключения TLS: приложение за reverse proxy должно понимать, что пользователь пришел по HTTPS. |
Без корректного proxy header приложение за reverse proxy иногда может считать, что пользователь пришел по HTTP, хотя снаружи уже используется HTTPS.
Включаем конфигурацию и проверяем nginx -t

Файл в sites-available сам по себе еще не активен.
Создадим символическую ссылку:
sudo ln -s /etc/nginx/sites-available/django-app /etc/nginx/sites-enabled/Теперь конфигурация появилась среди включенных сайтов.
Перед перезагрузкой Nginx обязательно проверим синтаксис: sudo nginx -t
При корректной конфигурации получим примерно:
syntax is ok
test is successful
И только после этого применяем изменения: sudo systemctl reload nginx
Именно reload, а не обязательно полноценный restart.
При reload Nginx перечитывает конфигурацию без обычной полной остановки сервиса.
Если на VPS оставлена стандартная конфигурация: /etc/nginx/sites-enabled/default, она иногда может мешать проверке по IP или перехватывать запросы, которые не подходят под наш server_name.
При необходимости стандартный сайт можно отключить: sudo rm /etc/nginx/sites-enabled/default
После чего снова выполнить:
sudo nginx -t
sudo systemctl reload nginx
Теперь проверим Nginx локально.
Например: curl -H "Host: example.com" http://127.0.0.1
Параметр: -H "Host: example.com" позволяет проверить нужный server_name еще до того, как реальный DNS будет окончательно настроен.
Если получаем страницу Django, значит Nginx успешно принимает запрос и передает его Gunicorn.
Теперь публичный HTTP-трафик уже может проходить через Nginx, а Gunicorn по-прежнему остается скрытым на локальном порту.
Следующий шаг — разобраться со static и media. Их мы не будем без необходимости прогонять через Django и Gunicorn: Nginx сможет отдавать такие файлы самостоятельно.
Раздаем static и media через Nginx
Чем static отличается от media
На первый взгляд и там и там лежат обычные файлы.
Но назначение у них разное.
Static — это файлы самого приложения:
- CSS;
- JavaScript;
- иконки;
- изображения интерфейса;
- шрифты;
- static-файлы Django Admin.
Обычно они поставляются вместе с кодом проекта.
Например:
/static/css/style.css
/static/js/app.js
/static/admin/css/base.css
А media — это файлы, которые появляются уже во время работы приложения.
Например:
- Аватары пользователей;
- Фотографии товаров;
- Загруженные документы;
- Вложения;
- Изображения из форм.
То есть:
static
→ часть приложения
media
→ пользовательский или генерируемый контент
Эта разница важна еще и при развертывании.
Static можно заново собрать из проекта через collectstatic.
А media так просто восстановить нельзя: это уже реальные данные пользователей, и для них нужен отдельный backup.
Почему их не должен раздавать Gunicorn
Технически Django может отдавать файлы сам.
Во время разработки именно так часто и происходит.
Но в production заставлять Gunicorn и Django заниматься каждым CSS-файлом или картинкой нет особого смысла.
Представим запрос: GET /static/css/style.css
Если отправлять его в Django, приложению придется принять запрос, обработать его через Python и только потом вернуть файл.
Хотя Nginx умеет просто взять нужный файл с диска и отправить клиенту напрямую.
Для веб-сервера это вполне естественная задача.
Поэтому мы разделяем обязанности:
- Динамические URL передаем Gunicorn;
- /static/ обслуживает Nginx;
- /media/ тоже обслуживает Nginx.
Так Python-процессы не тратятся на работу, которую гораздо проще выполнить веб-серверу.
Настраиваем STATIC_ROOT и MEDIA_ROOT
С STATIC_ROOT мы уже немного познакомились перед выполнением collectstatic.
Откроем: myproject/settings.py
и убедимся, что настройки выглядят примерно так:
STATIC_URL = "/static/"
STATIC_ROOT = BASE_DIR / "staticfiles"
MEDIA_URL = "/media/"
MEDIA_ROOT = BASE_DIR / "media"
Здесь важно не путать URL и путь на диске.
Например: STATIC_URL = "/static/" означает адрес, который увидит браузер: https://example.com/static/css/style.css
А: STATIC_ROOT = BASE_DIR / "staticfiles" означает реальный каталог на сервере: /var/www/django-app/staticfiles/
С media то же самое.
MEDIA_URL = "/media/" дает URL: https://example.com/media/avatar.jpg
а: MEDIA_ROOT = BASE_DIR / "media" указывает на: /var/www/django-app/media/
После изменения static-настроек снова можно выполнить:
cd /var/www/django-app
source venv/bin/activate
python manage.py collectstatic --noinput
Проверим каталоги:
ls -lah /var/www/django-app/staticfiles
ls -lah /var/www/django-app/media
Если media пока пустой или вообще еще не создан, это нормально.
Создадим его: mkdir -p /var/www/django-app/media
Теперь Nginx можно явно указать, откуда брать оба типа файлов.
Добавляем location /static/
Откроем конфигурацию сайта: sudo nano /etc/nginx/sites-available/django-app
Добавим отдельный location:
location /static/ {
alias /var/www/django-app/staticfiles/;
}
Здесь используется именно: alias
Nginx берет остаток URL после /static/ и ищет соответствующий файл внутри указанного каталога.
Например запрос: /static/css/style.css будет соответствовать файлу: /var/www/django-app/staticfiles/css/style.css
Обратите внимание на завершающий /: alias /var/www/django-app/staticfiles/;
В подобных конфигурациях лучше внимательно следить за слешами и не писать их наугад: сочетание location и alias напрямую влияет на итоговый путь к файлу.
Добавляем location /media/
Для пользовательских файлов настройка почти такая же:
location /media/ {
alias /var/www/django-app/media/;
}
Теперь полная конфигурация может выглядеть примерно так:
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
location /static/ {
alias /var/www/django-app/staticfiles/;
}
location /media/ {
alias /var/www/django-app/media/;
}
location / {
proxy_pass http://127.0.0.1:8000;
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;
}
}
Теперь запросы разделяются уже на уровне Nginx.
Static и media он отдает самостоятельно, а всё остальное передает приложению.
Есть здесь и важная оговорка.
Если в media лежат приватные файлы, которые должны быть доступны только авторизованным пользователям, бездумно открывать весь каталог через обычный location /media/ нельзя.
Такая конфигурация подходит для публичных загрузок — например аватаров или изображений товаров.
Для приватных документов нужна отдельная схема контроля доступа.
Проверяем файлы через браузер

Перед применением изменений проверим Nginx: sudo nginx -t
Если видим:
syntax is ok
test is successful
перечитываем конфигурацию: sudo systemctl reload nginx
Теперь найдем любой существующий static-файл.
Например, после collectstatic практически наверняка будут файлы Django Admin: find /var/www/django-app/staticfiles -type f | head
Можно увидеть что-то вроде: /var/www/django-app/staticfiles/admin/css/base.css
Тогда проверяем: http://example.com/static/admin/css/base.css
Или через curl:
curl -I -H "Host: example.com" \
http://127.0.0.1/static/admin/css/base.css
Нас интересует успешный HTTP-код: HTTP/1.1 200 OK
Для media можно временно создать тестовый файл: echo "media works" > /var/www/django-app/media/test.txt
И запросить:
curl -H "Host: example.com" \
http://127.0.0.1/media/test.txt
Если получаем: media works, значит Nginx видит каталог правильно.
После проверки файл можно удалить: rm /var/www/django-app/media/test.txt
Если вместо этого получаем: 403 Forbidden или 404 Not Found стоит проверить две вещи.
Во-первых, правильность пути в alias.
Во-вторых, права на сами каталоги:
namei -l /var/www/django-app/staticfiles
namei -l /var/www/django-app/media
Nginx должен иметь возможность пройти по родительским каталогам и прочитать нужные файлы.
Теперь Nginx обслуживает не только динамические запросы к Django, но и файловую часть приложения.
Остается сделать сайт удобным уже не для самого сервера, а для реального пользователя: подключить домен, а затем заменить обычный HTTP на HTTPS.
Подключаем домен
Создаем DNS A-запись
Начинаем с DNS.
У регистратора домена или DNS-провайдера нужно создать A-запись, которая укажет домен на публичный IPv4-адрес VPS.
Например:
| Тип | Имя | Значение |
| А | @ | 203.0.113.10 |
| А | www | 203.0.113.10 |
Здесь:
- @ означает корневой домен;
- www — поддомен www.example.com;
- 203.0.113.10 — пример адреса VPS.
После сохранения изменения DNS применяются не всегда мгновенно.
Проверить, куда сейчас указывает домен, можно через: dig +short example.com или: nslookup example.com
В результате должен появиться IP нашего сервера: 203.0.113.10
То же самое проверим для www: dig +short www.example.com
Если старый IP все еще возвращается, Nginx трогать пока бессмысленно: запросы просто не доходят до нужного VPS.
Время обновления зависит в том числе от TTL записи и DNS-кэшей, поэтому иногда приходится немного подождать.
Настраиваем server_name и ALLOWED_HOSTS
Когда DNS уже смотрит на наш VPS, нужно настроить два разных уровня.
Первый — Nginx.
Открываем: sudo nano /etc/nginx/sites-available/django-app
И указываем домен проекта в server_name. В нашем примере: server_name example.com www.example.com;
Так Nginx понимает, какой server block использовать для запросов к этим именам.
Теперь Django.
В .env ранее было: ALLOWED_HOSTS=example.com,www.example.com
Для своего проекта здесь нужно указать те же доменные имена, которые используются в server_name. В нашем примере оставляем: ALLOWED_HOSTS=example.com,www.example.com
Django читает эту строку в settings.py и превращает ее в список разрешенных host-имен.
Здесь полезно понимать разницу:
| Настройка | Кто использует | За что отвечает |
| server_name | Nginx | Выбирает конфигурацию для конкретного домена |
| ALLOWED_HOSTS | Django | Разрешает Django обслуживать запросы с этим Host |
То есть одного server_name недостаточно.
Nginx может совершенно правильно передать запрос в Gunicorn, но Django затем ответит: DisallowedHost, если домен отсутствует в ALLOWED_HOSTS.
После изменения .env перезапустим Gunicorn: sudo systemctl restart gunicorn
А после редактирования Nginx: sudo nginx -t
Если конфигурация корректна:
syntax is ok
test is successful
применяем: sudo systemctl reload nginx
Проверяем сайт по HTTP
Теперь можно проверить уже всю публичную цепочку.
В браузере открываем: http://example.com
И отдельно: http://www.example.com
Либо используем curl: curl -I http://example.com
Если приложение работает, получим ожидаемый HTTP-ответ, например: HTTP/1.1 200 OK
Можно проверить и подробности: curl -v http://example.com
Если домен открывает Django-приложение, значит сразу несколько уровней уже настроены правильно:
- DNS ведет на нужный VPS;
- Порт 80 доступен;
- Nginx принимает запрос;
- Server_name совпадает;
- Gunicorn работает;
- Django принимает домен через ALLOWED_HOSTS.
Оставлять сайт на обычном HTTP мы, разумеется, не будем.
Подключаем HTTPS через Certbot.
При обычном HTTP данные между браузером и сервером передаются без TLS-шифрования. Это особенно критично для форм авторизации, cookie, административной панели и любых пользовательских данных.
После подключения HTTPS адрес станет выглядеть так:
Для получения бесплатного TLS-сертификата воспользуемся Certbot и Let's Encrypt.
Устанавливаем Certbot и получаем сертификат
Установим Certbot вместе с плагином для Nginx:
sudo apt update
sudo apt install -y certbot python3-certbot-nginx
Проверим: certbot --version
Теперь запросим сертификат:
sudo certbot --nginx -d example.com -d www.example.comЗдесь: -d example.com и -d www.example.com означают доменные имена, которые должны попасть в сертификат.
Certbot попросит указать email, принять условия использования и затем попробует подтвердить владение доменом.
Поэтому к этому моменту DNS уже должен вести на наш VPS, а Nginx — нормально отвечать по HTTP.
При успешном выпуске Certbot сообщит, что сертификат получен, и сохранит его примерно в: /etc/letsencrypt/live/example.com/
Там находятся, в частности:
fullchain.pem
privkey.pem
Вручную копировать эти файлы в каталог Django не нужно.
Certbot умеет сам настроить Nginx на их использование.
После этого проверим: sudo nginx -t и sudo systemctl reload nginx
Теперь сайт должен открываться через: https://example.com
Проверить можно и из терминала: curl -I https://example.com
Настраиваем редирект на HTTPS
После подключения TLS желательно не оставлять две независимые версии сайта: http://example.com и https://example.com
Обычные HTTP-запросы будем перенаправлять на защищенную версию.
Certbot с плагином Nginx обычно умеет добавить такой redirect автоматически.
В конфигурации в итоге появится логика примерно такого вида:
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
return 301 https://$host$request_uri;
}
Теперь запрос: http://example.com/admin/
перенаправляется на: https://example.com/admin/
Проверим: curl -I http://example.com
Ожидаем код перенаправления: HTTP/1.1 301 Moved Permanently и заголовок: Location: https://example.com/
Здесь стоит сделать еще одну важную настройку на стороне Django.
Поскольку TLS завершается на Nginx, сам Gunicorn получает локальный проксированный запрос. Поэтому Django нужно корректно понимать переданный X-Forwarded-Proto.
В settings.py можно указать:
SECURE_PROXY_SSL_HEADER = (
"HTTP_X_FORWARDED_PROTO",
"https",
)
Ранее в Nginx мы уже добавили: proxy_set_header X-Forwarded-Proto $scheme;
Эта настройка особенно полезна, если дальше будут использоваться HTTPS-зависимые механизмы Django.
Для production также стоит включить secure-флаги cookie:
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
А если приложение принимает POST-запросы через формы или административную панель, укажем доверенный HTTPS-origin:
CSRF_TRUSTED_ORIGINS = [
"https://example.com",
"https://www.example.com",
]
После изменения Django-настроек: sudo systemctl restart gunicorn
Проверяем автопродление

Сертификаты Let's Encrypt имеют ограниченный срок действия, поэтому вручную перевыпускать их каждые несколько месяцев было бы сомнительным удовольствием.
Certbot устанавливает механизм автоматического продления.
Проверим соответствующий timer: systemctl status certbot.timer --no-pager
Также можно посмотреть расписание: systemctl list-timers | grep certbot
Но главный тест — имитация продления: sudo certbot renew --dry-run
--dry-run позволяет проверить процесс без реального перевыпуска рабочего сертификата.
Если всё настроено правильно, Certbot успешно пройдет тест.
Можно также посмотреть уже установленные сертификаты: sudo certbot certificates
Там будут показаны домены и срок действия сертификата.
После этого финально проверяем сайт curl -I https://example.com и обычный HTTP: curl -I http://example.com
Первый должен нормально обслуживаться по HTTPS, второй — перенаправляться на него.
Осталась менее приятная, но очень полезная часть — разобрать, куда смотреть, если один из элементов всей этой конструкции внезапно решил не работать.
Если приложение не заработало: разбираем типовые проблемы
502 Bad Gateway: проверяем Nginx и Gunicorn
502 Bad Gateway обычно означает, что Nginx сам работает, но не может получить нормальный ответ от backend.
В нашей конфигурации backend — это Gunicorn на: 127.0.0.1:8000
Начнем с него.
Проверим сервис: sudo systemctl status gunicorn --no-pager
Если Gunicorn остановлен: sudo systemctl start gunicorn или: sudo systemctl restart gunicorn
После этого проверим порт: sudo ss -lntp | grep 8000
Если Gunicorn действительно слушает нужный адрес, увидим 127.0.0.1:8000.
Теперь самый полезный тест: curl http://127.0.0.1:8000
Если Django отвечает напрямую, а через Nginx все равно приходит 502, значит проблема уже ближе к конфигурации proxy.
Проверяем: proxy_pass http://127.0.0.1:8000;
Адрес и порт должны совпадать с тем, что указан в gunicorn.service.
Например, если Gunicorn слушает 8000, а Nginx отправляет запросы на 8080, синтаксис конфигурации может быть абсолютно корректным, но приложение работать не будет.
Именно поэтому: sudo nginx -t
проверяет конфигурацию Nginx на синтаксические ошибки, но не гарантирует, что указанный backend реально существует.
Django не подключается к PostgreSQL
Если Gunicorn запускается, но приложение падает с ошибкой базы данных, возвращаемся к PostgreSQL.
Сначала проверяем сам сервер: sudo -u postgres pg_isready
Затем: sudo systemctl status postgresql --no-pager
Если PostgreSQL работает, пробуем подключиться под тем же пользователем, который указан в .env: psql -h 127.0.0.1 -U django_user -d django_db
Если это подключение не проходит, проблема уже не в Django.
Проверяем:
DB_NAME=django_db
DB_USER=django_user
DB_PASSWORD=...
DB_HOST=127.0.0.1
DB_PORT=5432
Особенно часто встречаются:
- Неправильный пароль;
- Ошибка в имени базы;
- Другой пользователь;
- Недостаточные права;
- PostgreSQL не запущен.
Если через psql подключение работает, а Django все равно жалуется, стоит проверить, что .env действительно читается приложением и Gunicorn использует нужное виртуальное окружение.
Static или media возвращают 404
Если само приложение открывается, но CSS пропал, Django Admin выглядит как привет из 2003 года или пользовательские изображения возвращают 404, скорее всего, проблема уже не в Gunicorn.
Для static сначала проверяем, выполнялся ли: python manage.py collectstatic --noinput
и существуют ли файлы: ls -lah /var/www/django-app/staticfiles
Затем смотрим Nginx:
location /static/ {
alias /var/www/django-app/staticfiles/;
}
Для media:
location /media/ {
alias /var/www/django-app/media/;
}
Проверяем реальный файл: find /var/www/django-app/staticfiles -type f | head
и запрашиваем его напрямую: curl -I http://example.com/static/admin/css/base.css
Если файл существует на диске, но Nginx его не отдает, смотрим путь в alias и права на каталоги: namei -l /var/www/django-app/staticfiles
Для media логика та же.
Важно помнить и еще одну вещь: collectstatic работает только со static.
Если пользовательский файл отсутствует в MEDIA_ROOT, никакая настройка Nginx его не создаст.
DisallowedHost и ошибки домена
Ошибка вида:
DisallowedHost
Invalid HTTP_HOST header
означает, что запрос дошел до Django, но приложение не принимает указанное доменное имя.
Проверяем .env: ALLOWED_HOSTS=example.com,www.example.com
и соответствующую настройку:
ALLOWED_HOSTS = [
host.strip()
for host in os.getenv("ALLOWED_HOSTS", "").split(",")
if host.strip()
]
После изменения .env перезапускаем Gunicorn: sudo systemctl restart gunicorn
Заодно проверяем Nginx: server_name example.com www.example.com;
И DNS: dig +short example.com
Домен должен вести именно на IP текущего VPS.
Если HTTP работает, а проблемы начинаются только после подключения HTTPS, дополнительно проверяем:
CSRF_TRUSTED_ORIGINS = [
"https://example.com",
"https://www.example.com",
]
Это особенно актуально для POST-запросов, форм и Django Admin.
Где смотреть логи
Когда по внешнему виду ошибки ничего не понятно, лучше сразу идти в логи.
Для Gunicorn: sudo journalctl -u gunicorn -n 50 --no-pager
или в реальном времени: sudo journalctl -u gunicorn -f
Для Nginx: sudo tail -f /var/log/nginx/error.log и sudo tail -f /var/log/nginx/access.log
Для PostgreSQL можно начать с: sudo journalctl -u postgresql -n 50 --no-pager
Если ошибка возникает именно внутри Django, она часто всплывет в журнале Gunicorn, потому что именно этот процесс запускает приложение.
Получается довольно практичное правило:
| Симптом | Куда смотреть сначала |
| 502 Bad Gateway | Gunicorn и proxy_pass |
| Ошибка подключения к БД | PostgreSQL и .env |
| Static/media дают 404 | STATIC_ROOT, MEDIA_ROOT, alias |
| DisallowedHost | ALLOWED_HOSTS, server_name, DNS |
| Непонятная внутренняя ошибка | journalctl и Nginx logs |
Чаще всего проблема находится именно там, где заканчивается последний успешно работающий слой.
Если Nginx отвечает, но backend недоступен — смотрим Gunicorn. Если Gunicorn жив, но Django падает на запросе — проверяем настройки приложения и PostgreSQL. Если HTML открывается, а CSS нет — уже не трогаем базу и идем в static.
Так диагностика занимает заметно меньше времени, чем попытка одновременно перезапустить вообще всё на сервере.
Заключение

На этом наше Django-приложение прошло путь от обычного проекта на пустом VPS до полноценного production-развертывания.
Мы подготовили Ubuntu, создали отдельное Python-окружение, подключили PostgreSQL и вынесли чувствительные параметры из settings.py. После этого подготовили миграции и static, заменили development server на Gunicorn и передали управление процессом systemd.
Nginx, в свою очередь, стал внешней точкой входа: он проксирует динамические запросы в Gunicorn, самостоятельно раздает static и media, работает с доменом и принимает HTTPS-трафик.
В результате каждый компонент занимается своей задачей, а приложение больше не зависит от открытого SSH-терминала или ручного запуска python manage.py runserver.
При этом production-развертывание на этом не заканчивается навсегда. По мере роста проекта к такой схеме могут добавляться резервное копирование PostgreSQL и media, Redis, Celery, мониторинг, централизованные логи, CI/CD или несколько экземпляров приложения за балансировщиком.
Но фундамент уже готов. А главное — теперь понятно не только какие команды выполнять, но и зачем в этой конструкции вообще понадобился каждый ее элемент.
FAQ
Можно ли развернуть Django без Nginx и открыть Gunicorn напрямую?
Технически — да. Gunicorn можно привязать к публичному интерфейсу и открыть соответствующий порт.
Для обычного production-развертывания удобнее оставить Gunicorn на 127.0.0.1 и поставить перед ним Nginx. Тогда веб-сервер берет на себя HTTPS, домен, static, media и другую работу с внешним HTTP-трафиком.
Нужно ли перезапускать Gunicorn после изменения кода?
Да. Уже запущенные worker-процессы не обязаны автоматически подхватывать измененный production-код.
После обновления проекта обычно выполняют: sudo systemctl restart gunicorn
Если при обновлении изменились модели или static, дополнительно могут понадобиться:
python manage.py migrate
python manage.py collectstatic --noinput
Именно такие последовательности позже часто автоматизируют через CI/CD.
Можно ли оставить SQLite, если сайт небольшой?
Можно.
SQLite не становится запрещенной только потому, что проект оказался на VPS. Для небольшого внутреннего сервиса, прототипа или приложения с очень небольшой нагрузкой ее возможностей может быть достаточно.
PostgreSQL становится особенно полезен, когда появляются параллельные запросы, более серьезная работа с данными, несколько процессов приложения и требования к дальнейшему масштабированию.
Почему после DEBUG=False сайт неожиданно перестал открываться?
Одна из самых частых причин — ALLOWED_HOSTS.
При отключенном debug Django ожидает корректный список разрешенных host-имен. Поэтому production-домен нужно добавить, например, через используемую нами переменную:
ALLOWED_HOSTS=example.com,www.example.com
После изменения окружения не забудьте перезапустить Gunicorn.
Почему Django Admin открывается без стилей?
Обычно это означает, что проблема не в самой административной панели, а в static.
Стоит проверить: python manage.py collectstatic --noinput на наличие файлов в STATIC_ROOT и соответствующий location /static/ в Nginx.
Django Admin использует собственные CSS и JavaScript, поэтому проблемы с раздачей static там становятся особенно заметны.
Нужно ли делать backup каталога staticfiles?
Как правило, staticfiles можно заново получить командой: python manage.py collectstatic
Поэтому гораздо важнее резервировать то, что нельзя восстановить из исходного кода: PostgreSQL, пользовательские media, необходимые секреты и другую уникальную информацию приложения.
Что произойдет, если сертификат Let's Encrypt закончится?
При нормально настроенном Certbot сертификат должен продлеваться автоматически до истечения срока.
Именно поэтому после настройки HTTPS полезно проверить механизм заранее: sudo certbot renew --dry-run
Если тест проходит успешно, вручную перевыпускать сертификат каждый раз не требуется.
Что проверять после каждого обновления Django-приложения?
Минимальный набор зависит от самого обновления, но полезно убедиться, что Gunicorn работает, миграции применены, static актуальны, а приложение отвечает через Nginx.
После более серьезных изменений стоит также посмотреть логи и протестировать основные пользовательские сценарии — открыть сайт недостаточно, если, например, форма авторизации или запись в PostgreSQL уже перестали работать.


