Друзья, привет.
Есть такая классическая ситуация во фрилансе: берёшь проект, пишешь код, а потом заказчик смотрит на тебя и говорит — «ну и когда уже на сервере будет?». Девопса в команде нет, бюджета на него тоже, зато есть ты и арендованный VPS.
Эта статья — про то, как не превратить этот момент в боль. Я покажу простой и рабочий способ держать несколько проектов на одном сервере так, чтобы они не мешали друг другу, каждый был доступен по своему домену с HTTPS, и добавление нового сервиса занимало минуты, а не часы.
Kubernetes здесь не будет — это инструмент для другого масштаба и другой команды. Нас интересует решение, которое один человек может поднять, понять и потом поддерживать без документации на 300 страниц.
Выделенные и виртуальные серверы в Европе, США и России |
Готовые серверы + предустановленное программное обеспечение, а также индивидуальные конфигурации серверов. |
Дисклеймер
Мы не трогаем CI/CD, GitLab Pipelines и прочую автоматизацию — это темы для отдельных статей. Целевая аудитория здесь: разработчик, которому нужно самому задеплоить свой проект, и человек, который только начинает разбираться с серверами. Если вы уже гоняете Helm-чарты — вам скорее всего неинтересно, но можете остаться ради архитектурного раздела.
Что нужно понимать на старте
Базовое знакомство с терминалом и умение написать простой веб-сервис на любом языке — этого достаточно. Весь остальной контекст я буду объяснять по ходу.
Технологии, которые будем использовать
Прежде чем лезть в настройки, коротко пробежимся по инструментам. Без фанатизма — просто чтобы понимать, зачем каждый из них здесь.
Git + GitHub — хранилище для проектов. Код лежит на GitHub, на сервере мы его просто клонируем через git clone. Никаких FTP и ручной загрузки файлов.
Docker + Docker Compose — если вы с ними ещё не работали, не пугайтесь. Docker позволяет упаковать проект вместе со всеми его зависимостями в контейнер, который одинаково запускается на любом сервере. Docker Compose — это способ описать несколько таких контейнеров в одном файле и поднять их одной командой.
Nginx Proxy Manager (NPM) — главный герой статьи. Это reverse-proxy с веб-интерфейсом, который берёт на себя всю маршрутизацию: какой домен ведёт на какой сервис, где выдавать SSL-сертификат, где редиректить с HTTP на HTTPS. Вместо того чтобы вручную писать конфиги Nginx, вы кликаете в браузере. Его мы тоже запустим в Docker-контейнере.
Подготовим проект под деплой
В качестве подопытного кролика я набросал простой монолит: FastAPI + Jinja2 + Tailwind. Никакой магии — обычный веб-сервис с HTML-страницами, который локально запускается одной командой python main.py. Именно такой проект чаще всего и нужно задеплоить.
Архитектуру и код разбирать подробно не буду — это не тема статьи. Покажу только структуру, чтобы было понятно, с чем работаем.
Создаём виртуальное окружение и активируем его:
python3 -m venv venv source venv/bin/activate
Создаем файл requirements.txt и заполняем его следующим образом:
# Web-фреймворк fastapi==0.137.0 # ASGI-сервер (standard добавляет uvloop, httptools, watchfiles для --reload) uvicorn[standard]==0.49.0 # Шаблонизатор jinja2==3.1.6 # Настройки приложения через переменные окружения / .env pydantic-settings==2.14.1 # Парсинг form-data (нужен FastAPI для обработки HTML-форм) python-multipart==0.0.32
Архитектура проекта выглядит так:
├── main.py # точка входа: python3 main.py → uvicorn ├── router.py # агрегатор всех роутеров ├── requirements.txt ├── core/ │ ├── config.py # настройки (pydantic-settings) │ └── templating.py # экземпляр Jinja2Templates ├── routers/ │ ├── pages.py # HTML-страницы │ └── api.py # пример JSON-эндпоинта ├── static/ │ ├── css/style.css # свой CSS поверх Tailwind │ └── js/main.js └── templates/ ├── base.html # layout + Tailwind CDN ├── partials/ # навбар, футер └── pages/ # контент страниц
Несколько решений, которые стоит отметить:
Tailwind подключён через CDN прямо в base.html — никакого Node.js и сборки;
Приложение собирается через фабрику create_app() в main.py, запускается через uvicorn.run();
Шаблоны через наследование: base.html + {% include %} для партиалов;
Запуск локально:
pip install -r requirements.txt python3 main.py # → http://127.0.0.1:8000


Обратите внимание: Dockerfile и docker-compose здесь намеренно нет. Именно в таком виде — «просто Python-проект» — чаще всего и лежит код у разработчика, которому внезапно нужно что-то задеплоить.
Дальше я покажу, как запустить такой проект на VPS как есть, без контейнеров. Потом объясню, почему это работает, но создаёт проблемы. И после этого мы завернём всё в Docker и сделаем нормально.
А пока — пушим проект на GitHub.
Создаём репозиторий на GitHub и пушим проект
Понимаю, что для многих этот раздел покажется очевидным — можете смело листать дальше. Но раз уж статья для тех, кто только начинает разбираться с деплоем, пропускать его не буду.
Итак наши шаги:
1. Регистрируемся на GitHub — если аккаунта ещё нет, это пять минут на github.com.
2. Создаём новый репозиторий — жмём «New repository», даём имя проекту.

3. Выбираем видимость — я поставлю приватный. Чуть позже покажу, как клонировать приватный репозиторий на сервер через SSH-ключ. Когда статья выйдет, репозиторий сделаю публичным — сможете клонировать и посмотреть финальный код.
Скрытый текст
Собственно вот ссылка на репозиторий.

4. Авторизация на локальной машине — настройку SSH-ключей для GitHub описывать не буду, в сети этого материала достаточно. Если ещё не настраивали — погуглите «GitHub SSH key setup», это десять минут.

5. Создаём .gitignore — чтобы не тащить в репозиторий мусор:
# Виртуальное окружение venv/ .venv/ env/ # Байт-код Python __pycache__/ *.py[cod] *$py.class # Настройки окружения .env # Кэш инструментов .pytest_cache/ .mypy_cache/ .ruff_cache/ # IDE .idea/ .vscode/ # ОС .DS_Store
6. Пушим проект
git init git add . git commit -m "init commit" git branch -M main git remote add origin git@github.com:ВАШ_ЛОГИН/ВАШ_РЕПОЗИТОРИЙ.git git push -u origin main

После этого открываем репозиторий в браузере и проверяем, что все файлы на месте. .env и venv/ там быть не должно — если они всё же попали, значит .gitignore не был создан до git add ., придётся почистить историю.

Проект на GitHub лежит, можно двигаться дальше. Следующий шаг — арендовать VPS и взять доменное имя, к субдоменам которого мы будем цеплять наши сервисы.
Берем доменное имя
Хостер для регистрации домена особой роли не играет — берите где удобно. Я воспользуюсь reg.ru.
1. Регистрируемся и входим в личный кабинет;
2. Переходим в раздел регистрации домена: reg.ru/domain/new;
3. Проверяем доступность имени и добавляем в корзину. Здесь важный момент: reg.ru активно навязывает дополнительные услуги — хостинг, домены в других зонах, защиту whois. Всё это лишнее, платим только за сам домен;

4. Далее переходим в список доменов, находим купленный домен и проваливаемся в него

После покупки переходим в список доменов, находим свой и проверяем NS-серверы. Там должно стоять:
ns1.reg.ru ns2.reg.ru
Именно эти NS-серверы дают нам возможность управлять DNS-записями через интерфейс reg.ru — а значит, свободно привязывать субдомены к нашему VPS.
DNS пока не трогаем — вернёмся к настройке записей, когда у нас будет IP-адрес сервера. Сейчас идём арендовать VPS.
Арендуем VPS сервер
Я буду арендовать сервер у HOSTKEY. Для задач этой статьи хватит самого простого тарифа. Если планируете держать на сервере что-то требовательное к ресурсам, то берите машину помощнее, логика настройки от этого не меняется.
1. Регистрируемся на hostkey.ru и входим в личный кабинет;
2. Переходим в раздел «Новый сервер»;
3. Выбираем регион — я беру VPS в России;

4. В разделе «Сервер» выбираем «Виртуальные серверы» и подбираем подходящую конфигурацию. Я взял минимальный тариф за 490 ₽/мес — для демонстрации и лёгких проектов этого более чем достаточно.

5. Операционная система — Ubuntu 24.04, без дополнительного предустановленного софта. Чистая система, всё остальное поставим сами.

Остальные параметры оставляем по умолчанию и оплачиваем. Удобнее всего пополнить внутренний баланс аккаунта и при оплате выбрать «Оплатить с кредитного баланса» — так не нужно каждый раз вводить данные карты.
Мой финальный конфиг:

После оплаты переходим в раздел «Мои серверы» и ждём, пока статус сменится на «Доступен». Обычно это занимает пару минут. Данные для входа — IP-адрес, логин и пароль — придут на почту.


Настраиваем VPS
Заходим на сервер по SSH:
ssh root@ВАШ_IP
При первом подключении терминал спросит подтвердить fingerprint — пишем yes. Затем вводим пароль из письма.

Обновляем пакеты:
sudo apt update && sudo apt upgrade -y

Проверяем, что Docker и Docker Compose на месте:
docker -v && docker compose version
Если в консоли вы увидите вот такое:

То на сервер уже была поставлена Ubuntu с предустановленным Docker и можно двигаться дальше. Если команды не найдены — нужно доустановить, но на свежей Ubuntu 24.04 от HOSTKEY Docker обычно уже есть.
Примечание: в продовой среде на этом этапе принято создавать отдельного пользователя, выдавать ему нужные права и дальше работать из-под него, а не из-под root. Для целей этой статьи я этот шаг пропускаю — все команды дальше идут от root.
На этом базовая подготовка сервера закончена. Переходим к главному герою статьи — Nginx Proxy Manager.
Nginx Proxy Manager — что это и зачем нам нужно

Представьте ситуацию: у вас на сервере крутится три проекта. Один на порту 8000, второй на 8001, третий на 3000. Чтобы к ним добраться, нужно каждый раз писать в браузере http://ВАШ_IP:8000, http://ВАШ_IP:8001 и так далее. Про HTTPS вообще молчу — настраивать его вручную через конфиги Nginx отдельное удовольствие.
Nginx Proxy Manager решает эту проблему. По сути это reverse-proxy с удобным веб-интерфейсом, который сидит на вашем сервере, принимает все входящие запросы на 80 и 443 порты и сам разруливает куда что направить. Вы говорите ему: «запросы на project1.yourdomain.ru отправляй вот сюда, а project2.yourdomain.ru — вот туда». Всё это делается через браузер без единого конфига.
SSL-сертификаты NPM получает и обновляет сам через Let's Encrypt — вы просто ставите галочку.
Но главное не удобство, а безопасность. Правильный подход выглядит так: все ваши проекты работают внутри закрытой Docker-сети и снаружи недоступны вообще. Никаких торчащих наружу портов 8000, 8001 и прочих. Единственное, что смотрит в интернет — это сам NPM на портах 80 и 443. Он принимает запрос, проксирует его внутрь сети к нужному контейнеру и возвращает ответ.
Снаружи виден только NPM. Всё остальное — за закрытой дверью.
Мы сначала поднимем NPM и посмотрим как он работает. Потом запустим проект по-простому — через systemd, с портами наружу — и разберём почему так делать не стоит. А затем уберём все лишние порты, поднимем проект в Docker-сети и сделаем всё как надо.
Подготовим субдомены
Небольшое отступление перед тем как двигаться дальше.
В рамках этой статьи я подниму три проекта — намеренно разных, чтобы показать что подход работает одинаково хорошо и для своего кода, и для готовых образов с Docker Hub:
traefik/whoami — запускается буквально одной командой, сразу увидем NPM в деле
FastAPI + Jinja2 + Tailwind — наш собственный проект (сначала простой запуск, а после правильный с паковкой в докер контейнер)
Adminer + PostgreSQL — веб-морда для базы данных, причём сама база будет полностью закрыта снаружи и доступна только через Adminer. Тут будем делать сразу правильно.
Трёх примеров достаточно чтобы понять как система работает в целом.
Возвращаемся в личный кабинет reg.ru, проваливаемся в управление DNS нашего домена и добавляем три A-записи — по одной на каждый субдомен:
Субдомен | Тип | Значение |
|---|---|---|
whoami | A | IP вашего VPS |
fastapi-jinja2-tailwind | A | IP вашего VPS |
adminer | A | IP вашего VPS |
Все три записи смотрят на один и тот же IP-адрес сервера. Разруливать какой запрос куда направить — задача NPM, не DNS.

DNS-записи обновляются не мгновенно, propagation может занять от нескольких минут до пары часов (обычно 5-10 минут). Пока настраиваем сервер — записи как раз разойдутся.
Поднимаем Nginx Proxy Manager
Создаём папку для NPM и переходим в неё:
cd /opt/ mkdir nginx-proxy-manager && cd nginx-proxy-manager

Создаём файл compose.yaml с таким содержанием:
nano compose.yaml
Вставляем:
services: npm: image: jc21/nginx-proxy-manager:latest restart: unless-stopped ports: - "80:80" - "443:443" - "81:81" volumes: - ./data:/data - ./letsencrypt:/etc/letsencrypt
Три порта которые мы открываем:
80 — HTTP, весь входящий трафик;
443 — HTTPS, весь входящий трафик;
81 — веб-интерфейс самого NPM.
Volumes нужны чтобы данные NPM и SSL-сертификаты не исчезали при перезапуске контейнера.
Поднимаем:
docker compose up -d

Заходим в браузере на http://ВАШ_IP:81 и видим форму регистрации.

Указываем свое имя, почту и придумываем пароль. Затем нажимаем на Save.
После успешного входа вы должны будете увидеть такую панель:

К панели мы вернемся чуть позже, а пока запустим на сервере наш первый проект.
Запускаем первый проект — traefik/whoami
Возвращаемся в директорию opt и запускаем whoami одной командой:
cd ../ docker run -d --name whoami -p 8080:80 traefik/whoami
Проверяем что контейнер поднялся:
docker ps

Убеждаемся что проект отвечает — заходим в браузере на http://ВАШ_IP:8080. Видим страницу с информацией о запросе — IP, заголовки, hostname. Проект работает.

Теперь идём в NPM и привязываем домен.
Привязываем доменное имя и включаем HTTPS
Проверим, что домен связан с IP нашего VPS сервера:
ping whoami.ВАШ_ДОМЕН.ru
В моем случае команда выглядит так:
ping whoami.yakvenalex.ru

Открываем http://ВАШ_IP:81 и входим в панель NPM.
Переходим в Proxy Hosts > Add Proxy Host и заполняем:
Details:
Domain Names: whoami.ВАШ_ДОМЕН.ru;
Scheme: http;
Forward Hostname / IP: IP вашего VPS;
Forward Port: 8080;
Включаем Websockets Support.
SSL:
SSL Certificate → Request a new SSL Certificate;
Включаем Force SSL;
Включаем HTTP/2 Support;
Ставим галочку I Agree to the Let's Encrypt Terms of Service.


NPM сам сходит на Let's Encrypt и получит сертификат. Через несколько секунд статус станет зелёным.

Заходим в браузере на https://whoami.ВАШ_ДОМЕН.ru — видим нашу страницу, в адресной строке замочек.
Первый проект задеплоен, домен привязан, HTTPS работает. И всё это без единого конфига Nginx.

Но обратите внимание — порт 8080 сейчас торчит наружу, проект доступен и напрямую по http://ВАШ_IP:8080. Это работает, но правильным такой подход назвать нельзя. Разберём почему — и исправим.
Закрываем порты и делаем всё правильно
Пока наш whoami доступен по двум адресам одновременно — через домен с HTTPS и напрямую по http://ВАШ_IP:8080. Второй вариант это дыра: любой кто знает IP вашего сервера может зайти в проект напрямую, минуя NPM со всей его защитой и маршрутизацией.
Чем больше открытых портов — тем больше поверхность атаки. Представьте что у вас десять проектов и у каждого торчит наружу свой порт. Это неудобно, небезопасно и превращается в хаос при масштабировании.
Правильный подход другой: все проекты живут в закрытой Docker-сети и снаружи вообще не видны. Единственный кто смотрит в интернет — NPM. Он принимает запрос, сам идёт внутрь сети к нужному контейнеру и возвращает ответ. Никаких открытых портов у проектов нет вообще.
Переходим от теории к практике.
1. Останавливаем и удаляем старый контейнер:
docker stop whoami && docker rm whoami

2. Создаём закрытую Docker-сеть:
docker network create internal

3. Подключаем NPM к этой сети. Переходим в папку NPM и обновляем compose.yaml:
cd nginx-proxy-manager
nano compose.yaml
Содержимое:
services: npm: image: jc21/nginx-proxy-manager:latest restart: unless-stopped ports: - "80:80" - "443:443" - "81:81" volumes: - ./data:/data - ./letsencrypt:/etc/letsencrypt networks: - internal networks: internal: external: true
Перезапускаем NPM:
docker compose up -d
4. Создаём папку для whoami и compose.yaml внутри:
cd ..
mkdir whoami && cd whoami
nano compose.yaml
Содержимое:
services: whoami: image: traefik/whoami restart: unless-stopped networks: - internal networks: internal: external: true
Обратите внимание — секции ports нет вообще. Контейнер поднимается внутри сети и снаружи недоступен никак.
Поднимаем:
docker compose up -d
И тут важный момент. Вызываем:
docker ps

Что мы видим? Когда контейнер работает внутри Docker-сети без проброса портов наружу, NPM обращается к нему по внутреннему порту — тому, который слушает сам процесс внутри контейнера. Для whoami это 80, именно его и указываем в Forward Port.
Теперь про потенциальную ловушку. Если вы захотите поднять несколько сервисов с одинаковым внутренним портом — например, два разных контейнера которые оба слушают на 80 — конфликта на уровне сети не будет, Docker-сеть это спокойно переживёт. Каждый контейнер изолирован и имеет свой сетевой стек. NPM обращается к конкретному контейнеру по имени сервиса, так что whoami:80 и fastapi:80 — это два разных адреса внутри сети.
Конфликт портов возникает только если вы пробрасываете порты наружу через ports: и пытаетесь повесить два контейнера на один и тот же порт хоста. Именно поэтому в правильной схеме мы ports: у проектов не указываем вообще — и эта проблема исчезает сама собой.
5. Обновляем настройки в NPM.
Возвращаемся в панель http://ВАШ_IP:81, находим наш Proxy Host для whoami и редактируем его:
Forward Hostname / IP: whoami — имя сервиса из compose.yaml;
Forward Port: 80;

Теперь попробуйте открыть http://ВАШ_IP:8080 — соединение не установится. Проект живёт внутри сети и снаружи его не существует. Только NPM знает как до него добраться.
Вот это уже правильный подход. Именно так мы будем поднимать все остальные проекты.
Закрываем админку NPM
Сейчас панель управления NPM висит на http://ВАШ_IP:81 и доступна всем желающим. Это неудобно и небезопасно — порт торчит наружу, смена пароля не спасёт если кто-то устроит брутфорс.
Правильное решение — убрать 81 порт наружу и повесить админку на субдомен через сам же NPM. Да, NPM будет проксировать сам себя. Это работает.
Добавляем новую DNS запись, например, npm.ВАШ_ДОМЕН.ru.
Убеждаемся, что запись указывает на наш VPS

Теперь идём в панель и добавляем новый Proxy Host для админки — пока порт ещё открыт:
Domain Names: npm.ВАШ_ДОМЕН.ru;
Scheme: http;
Forward Hostname / IP: npm — имя сервиса из compose.yaml;
Forward Port: 81;
SSL: запрашиваем сертификат, включаем Force SSL.

Сохраняем, проверяем что https://npm.ВАШ_ДОМЕН.ru открывается и вы можете войти.
Теперь правим compose.yaml:
cd nginx-proxy-manager nano compose.yaml
Содержимое:
services: npm: image: jc21/nginx-proxy-manager:latest restart: unless-stopped ports: - "80:80" - "443:443" volumes: - ./data:/data - ./letsencrypt:/etc/letsencrypt networks: - internal networks: internal:
Порт 81:81 убрали. Перезапускаем:
docker compose up -d
Проверяем — http://ВАШ_IP:81 больше не отвечает, а https://npm.ВАШ_ДОМЕН.ru открывается как прежде. Теперь снаружи у нашего сервера открыты только 80 и 443 — и это правильно.
Запускаем FastAPI проект
Прежде чем клонировать репозиторий — пара слов о том как мы этого делать не будем.
Классический путь новичка выглядит так: клонируем репо, ставим виртуальное окружение, поднимаем проект через systemd на порту 8000, в NPM прописываем публичный IP и этот порт. Работает — но порт торчит наружу, systemd-юнит живёт своей жизнью, и при следующем проекте всё это превращается в кашу из процессов и открытых портов.
Мы этот путь пропускаем и сразу делаем правильно — Docker, закрытая сеть, никаких портов наружу.
Связываем VPS с GitHub по SSH
Чтобы не вводить токены при каждом git clone и git pull — настроим SSH-авторизацию между сервером и GitHub.
Генерируем SSH-ключ на сервере:
ssh-keygen -t ed25519 -C "your_email@example.com"
На все вопросы жмём Enter — путь и passphrase оставляем по умолчанию.
Смотрим публичный ключ:
cat ~/.ssh/id_ed25519.pub

Копируем вывод и идём на GitHub: Settings > SSH and GPG keys > New SSH key. Вставляем ключ, даём любое имя — например vps-hostkey.

Проверяем что связка работает:
ssh -T git@github.com
Должны увидеть:
Hi ВАШ_ЛОГИН! You've successfully authenticated, but GitHub does not provide shell access.

Теперь клонируем репозиторий:
cd ../ git clone git@github.com:ВАШ_ЛОГИН/ВАШ_РЕПОЗИТОРИЙ.git

cd ВАШ_РЕПОЗИТОРИЙ
Пакуем проект в Docker
Теперь подготовим проект к запуску в контейнере. Нам нужно создать три файла прямо внутри папки с проектом.
.dockerignore
Чтобы не тащить в образ лишнее — виртуальное окружение, кэш, метаданные IDE:
# Виртуальное окружение venv/ .venv/ env/ # Байт-код Python __pycache__/ *.py[cod] # Кэш инструментов .pytest_cache/ .mypy_cache/ .ruff_cache/ # Git и метаданные .git/ .gitignore # Окружение и документация .env README.md # IDE и ОС .idea/ .vscode/ .DS_Store
Содержимое Dockerfile
# Базовый образ Python FROM python:3.12-slim # Не пишем .pyc и не буферизуем stdout/stderr — удобнее логи в контейнере ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 WORKDIR /app # Сначала зависимости — слой кешируется, пока requirements.txt не менялся COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt # Затем исходный код COPY . . # Порт приложения внутри Docker-сети (наружу не пробрасывается) EXPOSE 8000 CMD ["python", "main.py"]
Обратите внимание на порядок COPY — сначала копируем requirements.txt и ставим зависимости, и только потом копируем весь код. Docker кеширует слои, поэтому если вы поменяли только код — зависимости пересобираться не будут. Мелочь, но при частых пересборках экономит время.
Содержимое compose.yaml
services: app: build: . container_name: npm-python-demo restart: unless-stopped environment: # Слушаем все интерфейсы внутри контейнера, иначе сервис # будет недоступен другим контейнерам в сети HOST: 0.0.0.0 PORT: 8000 # В контейнере reload не нужен DEBUG: "false" networks: - internal networks: internal: external: true
Секции ports нет намеренно — контейнер живёт внутри сети internal и снаружи недоступен вообще. NPM будет обращаться к нему по имени сервиса app на порту 8000.
Собираем и поднимаем:
docker compose up -d --build
Проверяем что контейнер запустился:
docker ps

Идём в панель NPM и добавляем новый Proxy Host:
Domain Names: fastapi-jinja2-tailwind.ВАШ_ДОМЕН.ru;
Scheme: http;
Forward Hostname / IP: app;
Forward Port: 8000;
SSL: запрашиваем сертификат, включаем Force SSL.

Сохраняем, заходим на https://fastapi-jinja2-tailwind.ВАШ_ДОМЕН.ru — проект работает, порты наружу не торчат.

Adminer + PostgreSQL на закрытом контуре
Прежде чем поднимать — небольшой, но важный разговор о базах данных.
В продовых проектах база данных никогда не должна быть доступна снаружи. Никаких открытых портов, никакого 5432 торчащего в интернет. Это одно из базовых правил безопасности которое нарушают удивительно часто — особенно когда нужно «быстро посмотреть что там в базе» и разработчик на скорую руку открывает порт наружу, а потом забывает закрыть.
Правильный подход: PostgreSQL живёт исключительно на внутреннем контуре. Для работы с данными — дампы через pg_dump, а для визуального просмотра и редактирования — Adminer, который тоже живёт внутри сети и наружу смотрит только через NPM с HTTPS.
Получается чистая схема: вы заходите на https://adminer.ВАШ\_ДОМЕН.ru, вводите креды, работаете с базой — а сам PostgreSQL при этом не доступен снаружи вообще никак.
Что такое Adminer
Adminer — это веб-интерфейс для управления базами данных. Один PHP-файл, никаких зависимостей, поддерживает PostgreSQL, MySQL, SQLite и ещё несколько. Выглядит просто, работает надёжно. Для большинства задач — посмотреть таблицы, выполнить запрос, быстро отредактировать запись — этого более чем достаточно.
Создаем compose.yaml
Создаём папку и файл:
cd ../ mkdir adminer && cd adminer nano compose.yaml
Содержимое:
services: postgres: image: postgres:16-alpine restart: unless-stopped environment: POSTGRES_DB: demo POSTGRES_USER: admin POSTGRES_PASSWORD: ваш_надёжный_пароль volumes: - postgres_data:/var/lib/postgresql/data networks: - internal # Порты наружу намеренно не пробрасываем — # база доступна только внутри сети adminer: image: adminer:latest restart: unless-stopped networks: - internal # Порты тоже не пробрасываем — # доступ только через NPM networks: internal: external: true volumes: postgres_data:
Поднимаем:
docker compose up -d

Проверяем:
docker ps

Оба контейнера должны быть в статусе Up. Порты наружу не торчат ни у одного.
Идём в NPM и добавляем Proxy Host для Adminer:
Domain Names: adminer.ВАШ_ДОМЕН.ru;
Scheme: http;
Forward Hostname / IP: adminer;
Forward Port: 8080;
SSL: запрашиваем сертификат, включаем Force SSL.

Сохраняем, заходим на https://adminer.ВАШ_ДОМЕН.ru. В форме входа указываем:
System: PostgreSQL;
Server: postgres — имя сервиса из compose.yaml;
Username: admin;
Password: ваш пароль;
Database: demo.
Входим — видим интерфейс базы данных. PostgreSQL при этом снаружи недоступен никак, только Adminer знает как до него добраться внутри сети.

Работаем с Adminer
Мы фактически создали пустую базу данных без единой таблицы. Исправим это: прямо в Adminer создадим таблицу пользователей и заодно разберёмся, как там с таблицами работать на практике.
Выполним вход.
В форме входа указываем:
Движок: PostgreSQL;
Сервер: postgres — имя сервиса из compose.yaml (не db, у нас сервис называется postgres);
Имя пользователя: admin;
Пароль: ваш пароль который указали в POSTGRES_PASSWORD;
База данных: demo.

Если у вас в compose.yaml сервис назван db а не postgres, тогда в поле Сервер пишем db. Имя сервиса и есть hostname внутри Docker-сети.
После успешного входа вы увидите:

В Adminer после входа:
1. Жмём SQL-запрос;
2. Вставляем и выполняем:
CREATE TABLE users ( id SERIAL PRIMARY KEY, name VARCHAR(100) NOT NULL, email VARCHAR(100) UNIQUE NOT NULL, created_at TIMESTAMP DEFAULT NOW() ); INSERT INTO users (name, email) VALUES ('Алексей Иванов', 'alexey@example.com'), ('Мария Петрова', 'maria@example.com'), ('Дмитрий Сидоров', 'dmitry@example.com'), ('Анна Козлова', 'anna@example.com'), ('Сергей Новиков', 'sergey@example.com'), ('Елена Морозова', 'elena@example.com'), ('Павел Волков', 'pavel@example.com'), ('Ольга Лебедева', 'olga@example.com'), ('Николай Соколов', 'nikolay@example.com'), ('Татьяна Попова', 'tatyana@example.com');

После этого жмём Select на таблицу users — видим все десять записей в красивой таблице.

Это хорошо показывает саму идею: база закрыта, данные видны только через Adminer по защищённому домену.
Выводы
Давайте подведём итог тому, что мы сегодня сделали.
Взяли чистый VPS, подняли Nginx Proxy Manager, развернули три совершенно разных проекта — готовый образ с Docker Hub, собственный FastAPI-проект и связку Adminer + PostgreSQL. Каждый проект получил свой домен и HTTPS. При этом наружу смотрят только 80 и 443 порт — всё остальное закрыто внутри Docker-сети.
Несколько вещей которые хочу подчеркнуть отдельно:
Закрытые порты — это не паранойя, это база. Каждый открытый порт это потенциальная точка входа. Держать базу данных с открытым 5432 или сервис с торчащим наружу 8000 — это вопрос не «если», а «когда» это станет проблемой. Внутренняя Docker-сеть решает это раз и навсегда.
VPS — не страшно. Я надеюсь что после этой статьи сервер перестал казаться чёрным ящиком. Базовый деплой — это несколько команд, пара файлов и немного понимания как устроена сеть. Ничего магического.
NPM экономит кучу времени. Без него каждый новый проект — это ручная правка конфигов Nginx, возня с сертификатами и неизбежные опечатки. С ним — добавил Proxy Host, поставил галочку SSL, готово.
Я показал базу — фундамент на котором строится нормальный деплой. Но девопс этим не ограничивается. Если тема зашла, в следующих статьях могу разобрать:
CI/CD — автоматический деплой при пуше в репозиторий, без ручного захода на сервер вообще
Мониторинг — как понять что что-то упало раньше чем это заметит заказчик
Бэкапы — потому что pg_dump руками это не стратегия
Docker Swarm или Nomad — когда одного VPS уже не хватает, но Kubernetes ещё избыточен
Напишите в комментариях что интереснее и мы учтем это в следующих наших статьях.
Выделенные и виртуальные серверы в Европе, США и России |
Готовые серверы + предустановленное программное обеспечение, а также индивидуальные конфигурации серверов. |
