SSD Nodes Learn Hosting plans →
Посібники Matt ConnorВід Matt Connor

Docker Compose на VPS: бази для Ubuntu 24.04

Встановіть Docker Engine і Compose на Ubuntu 24.04, запустіть Miniflux із PostgreSQL, обійдіть пастку портів ufw та безпечно створюйте резервні копії томів.

Що ви створюєте

Docker Compose є основою майже всього іншого на цьому сайті. Nextcloud, Vaultwarden, n8n, Immich, Rocket.Chat — кожен із цих посібників починається з інструкції «створіть цей compose-файл». На цій сторінці пояснюється, що саме означає цей файл. Ви встановите Docker Engine і плагін Compose з власного apt-репозиторію Docker в Ubuntu 24.04, а потім запустите справжній стек із двох сервісів: Miniflux, невеликий RSS-агрегатор, і PostgreSQL. Ця пара охоплює всі шаблони, які використовують більші застосунки: зафіксовані образи, базу даних із healthcheck, іменований том, секрети у файлі .env і порт, опублікований лише для localhost.

Встановлення займає п’ять хвилин. Решту посібника присвячено проблемам, які виникають пізніше: група docker фактично надає права root, опубліковані порти обходять правила ufw, а один прапорець у docker compose down видаляє базу даних без запиту підтвердження.

Попередні вимоги: чистий Ubuntu 24.04 KVM VPS, користувач із sudo і щонайменше 1 гігабайт RAM. Наявне встановлення Docker також підходить: у першому розділі описано, що потрібно видалити.

Встановлюйте з репозиторію Docker, а не з репозиторію Ubuntu

Перед виконанням першої команди відмовтеся від двох неправильних варіантів. Власний пакет Ubuntu docker.io працює, але відстає від релізів Docker і не містить структури плагінів, на яку розраховані всі інші компоненти. А команда docker-compose із дефісом веде за шляхом, описаним у старих посібниках. На старішому сервері ця команда зазвичай встановлює Compose v1, написаний мовою Python. Docker оголосила його застарілим, і саме через нього старі посібники перестають працювати. Docker і далі публікує окремий бінарний файл docker-compose, зібраний із сучасного коду мовою Go, але на власній сторінці встановлення називає цей спосіб нерекомендованим і підтримує його лише для зворотної сумісності. Сьогодні Compose — це docker compose із пробілом, плагін CLI, який встановлюється з того самого репозиторію, що й engine.

Якщо щось із цього вже встановлено, спочатку видаліть це, зокрема docker-compose-v2 і docker-buildx — власні пакети Ubuntu для плагінів, — щоб усе надходило з одного репозиторію:

sudo apt remove -y docker.io docker-compose docker-compose-v2 docker-doc docker-buildx podman-docker containerd runc

Package 'docker.io' is not installed, so not removed — це звичайний результат на новому VPS. Потім додайте репозиторій Docker і встановіть пакети:

sudo apt update
sudo apt install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

Перевірте всі три рівні:

docker --version
docker compose version
sudo docker run --rm hello-world

Перші два виводять рядки з версіями. Docker Compose version v5.x.x підтверджує, що у вас встановлено плагін, а не застарілий бінарний файл v1. У грудні 2025 плагін перейшов безпосередньо з v2 на v5, пропустивши 3 і 4, щоб уникнути плутанини зі старими версіями формату файлів Compose. Тому посібники, у яких тут зазначено v2.x.x, описують той самий інструмент. Команда hello-world має завершитися з результатом Hello from Docker!. Пакет вмикає сервіс під час завантаження системи; systemctl is-enabled docker виводить enabled.

Група docker дає права root, тому вирішуйте усвідомлено

Зараз кожна команда docker потребує sudo, оскільки сокет демона за адресою /var/run/docker.sock належить root і групі docker. Без членства в цій групі ви отримаєте найпоширенішу помилку Docker у пошуку Google:

permission denied while trying to connect to the Docker daemon socket at
unix:///var/run/docker.sock

Стандартне виправлення:

sudo usermod -aG docker $USER

Членство в групі застосовується під час входу в систему, тому в поточній оболонці помилка не зникає. Виконайте newgrp docker для поточного сеансу або вийдіть із системи й увійдіть знову; після цього id має показати docker серед ваших груп.

Тепер прямо про головне: членство в групі docker фактично дає права root на хості. Не «майже root» і не «підвищені права», а root. Будь-хто з членством у цій групі може виконати docker run --rm -it -v /:/host alpine chroot /host і отримати повний контроль над файловою системою без запиту пароля. Ця група існує для зручності, а не для ізоляції.

Справжня альтернатива — rootless mode у Docker, коли сам daemon працює від імені вашого непривілейованого користувача. Це має обмеження: для портів нижче 1024 потрібне додаткове налаштування, мережа працює через userspace shim із вимірюваними накладними витратами, а деякі образи працюють некоректно без справжніх прав root. На VPS з одним адміністратором, який і так має доступ через sudo, група на практиці нічого не змінює. Саме це припускають усі інструкції тут, але не надавайте членство в групі так, ніби воно менш привілейоване, ніж sudo.

Структура compose-файлу

Виділіть для кожного стека окремий каталог. Назва каталогу стає назвою проєкту, яка додається до імен контейнерів, мереж і томів:

sudo mkdir -p /opt/miniflux && sudo chown $USER /opt/miniflux && cd /opt/miniflux

Створіть compose.yml (це сучасна назва; docker-compose.yml також працює). Не використовуйте застарілий ключ version:. Він більше не потрібен, і Compose показує попередження, якщо його знаходить.

services:
  miniflux:
    image: miniflux/miniflux:2.2.9
    restart: unless-stopped
    ports:
      - "127.0.0.1:8080:8080"
    environment:
      - DATABASE_URL=postgres://miniflux:${POSTGRES_PASSWORD}@db/miniflux?sslmode=disable
      - RUN_MIGRATIONS=1
      - CREATE_ADMIN=1
      - ADMIN_USERNAME=admin
      - ADMIN_PASSWORD=${ADMIN_PASSWORD}
    depends_on:
      db:
        condition: service_healthy

  db:
    image: postgres:16-alpine
    restart: unless-stopped
    environment:
      - POSTGRES_USER=miniflux
      - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
      - POSTGRES_DB=miniflux
    volumes:
      - db-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD", "pg_isready", "-U", "miniflux", "-d", "miniflux"]
      interval: 10s
      timeout: 5s
      retries: 5

volumes:
  db-data:

Кожен наведений вище рядок — окреме рішення. Розглянемо їх по черзі.

Фіксуйте версії образів: :latest разом із pull означає автоматичне оновлення

Використовуйте postgres:16-alpine, а не postgres:latest. Тег не фіксує версію: :latest щоразу підставляє версію, яку супроводжувач опублікував останньою, коли ви виконуєте pull. Якщо поєднати це зі звичкою регулярно оновлювати стек, про яку йтиметься далі, docker compose pull && docker compose up -d, то :latest означає, що переходи на нові основні версії відбуватимуться одразу після їх випуску, а не тоді, коли ви цього захочете. Для PostgreSQL це не теоретична проблема: несподіваний перехід з 16 на 17 залишає контейнер у циклічному перезапуску через несумісний каталог даних, оскільки оновлення основної версії Postgres потребує dump і restore, а не перезапуску.

Зафіксуйте принаймні основну версію (postgres:16-alpine отримує оновлення patch-рівня в межах 16.x), а версії застосунків — до конкретного релізу, наприклад miniflux/miniflux:2.2.9. Перевірте сторінку релізів проєкту та вкажіть актуальну версію на момент створення файлу. Після цього оновлення буде навмисною однорядковою зміною, видимою в git diff.

Публікуйте на 127.0.0.1, оскільки Docker обходить ufw

"127.0.0.1:8080:8080": адреса хоста, порт хоста, порт контейнера. У більшості посібників указано "8080:8080". Це скорочення для 0.0.0.0:8080:8080: прослуховування на всіх інтерфейсах, зокрема на публічному.

Ось типова пастка, у яку майже всі потрапляють хоча б раз. Docker публікує порт, додаючи правило DNAT, яке змінює адресу призначення пакета на внутрішню IP-адресу контейнера до фільтрації. Тому пакет проходить шлях FORWARD і не потрапляє до INPUT, де діють правила ufw. sudo ufw deny 8080 повідомляє про успішне виконання, ufw status показує, що порт заборонено, а сервіс усе одно відповідає всьому інтернету. Ваш firewall не зламаний. Його навмисно обходить Docker. У матеріалі Чому Docker обходить ufw і як справді фільтрувати трафік контейнерів описано механізм і виправлення DOCKER-USER для портів, які мають залишатися публічними.

Щоб повністю усунути цю проблему, публікуйте порти на 127.0.0.1, якщо немає конкретної причини робити інакше. Для сервісів, доступних з інтернету, використовуйте reverse proxy. Саме так побудовано наступний крок у посібнику з reverse proxy Traefik: один контейнер володіє портами 80 і 443 та маршрутизує запити до інших сервісів за іменем хоста, використовуючи TLS. Якщо ви переходите зі старої конфігурації Traefik v2, перейменування та зміни правил описано в посібнику з міграції Traefik v2 на v3.

Після запуску стека перевірте прив’язку порту: sudo ss -tlnp | grep 8080 має показувати 127.0.0.1:8080, а не 0.0.0.0:8080 або *:8080.

Іменовані томи та bind mounts

db-data:/var/lib/postgresql/data — це іменований том. Docker створює його та керує каталогом у /var/lib/docker/volumes/, після чого монтує його в контейнер. Альтернатива — bind mount, ./data:/var/lib/postgresql/data, який підключає шлях, вибраний вами на хості.

На практиці найкраще працює такий поділ: іменовані томи для даних, з якими працюють лише контейнери, насамперед для баз даних. Docker ініціалізує том із власником, якого очікує образ, тому права доступу до файлів зазвичай налаштовані правильно. Bind mounts використовуйте для файлів, з якими ви працюєте на хості: конфігураційних файлів, які редагуєте в текстовому редакторі, медіатеки, до якої виконуєте rsync, і всього, для чого важливо бачити явний шлях. Типова проблема bind mount — власник файлів. Образ запускає процес із фіксованим UID: образ Miniflux працює з UID 65534, postgres:16-alpine переходить на UID 70, а каталог на хості належить UID 1000. У результаті застосунок завершується під час запуску, а в його журналах з’являється permission denied. Іменовані томи здебільшого усувають цей клас проблем, але дані зберігаються за шляхом, яким керує Docker. Це питання розглянуто нижче.

environment і .env: не зберігайте секрети в git

Compose підставляє значення ${POSTGRES_PASSWORD} із файлу .env, розташованого поруч із compose.yml, але лише якщо shell ще не визначає цю змінну. Значення, експортоване в середовищі shell, має пріоритет над файлом .env. Тому забутий export POSTGRES_PASSWORD=... від попереднього експерименту непомітно перевизначає значення у файлі, а docker compose config нижче показує, яке значення було використано. Створіть файл:

cat > .env <<'EOF'
POSTGRES_PASSWORD=change-me-to-something-long
ADMIN_PASSWORD=change-me-too
EOF
chmod 600 .env
echo ".env" >> .gitignore

Генеруйте справжні значення за допомогою openssl rand -hex 24. Використовуйте hex, а не base64, навмисно: цей пароль потрапляє до рядка підключення DATABASE_URL, а символи /, + і =, які може створювати base64, порушують розбір URL. Помилка проявляється як помилка автентифікації, а не синтаксична помилка, і на її пошук можна витратити весь вечір. Рядок .gitignore має бути у файлі до першого commit. compose-файл безпечно публікувати та зберігати у version control, а файл .env — ніколи. Секрет, який потрапив до історії git, потрібно вважати скомпрометованим і замінити. Якщо запустити стек без змінної, Compose покаже попередження та продовжить роботу з порожнім рядком. Для пароля Postgres це означає несправне розгортання:

WARN[0000] The "POSTGRES_PASSWORD" variable is not set. Defaulting to a blank string.

docker compose config виводить файл після повної інтерполяції. Це найшвидший спосіб перевірити, які значення фактично отримають контейнери. Пам’ятайте, що у виводі будуть ваші секрети.

depends_on нічого не очікує, якщо не додати healthcheck

Звичайний depends_on: [db] визначає лише порядок запуску. Compose спочатку запускає Postgres, а через мить — застосунок, хоча Postgres ще кілька секунд не прийматиме підключення. Застосунок звертається до бази даних, отримує помилку та завершується або повторює спробу — залежно від якості його реалізації.

Надійний варіант використано у файлі вище: сервіс db визначає healthcheck (Postgres постачається з pg_isready саме для цього), а застосунок оголошує depends_on з condition: service_healthy. Compose запускає базу даних, перевіряє її стан кожні 10 секунд і запускає Miniflux лише після успішної перевірки. Якщо база даних так і не стане healthy через неправильний пароль або пошкоджений том, застосунок не запускається, а Compose повідомляє, яка залежність не пройшла перевірку:

dependency failed to start: container miniflux-db-1 is unhealthy

Це повідомлення вказує на docker compose logs db. Саме там міститься фактична помилка.

restart: unless-stopped

restart: unless-stopped для обох сервісів означає, що контейнери запускаються знову після збою та перезавантаження VPS, але залишаються зупиненими, якщо ви навмисно виконали docker compose stop. Альтернатива always запускає контейнери знову навіть після ручної зупинки. Зазвичай це не очікувана поведінка. Без політики restart перезавантаження після оновлення kernel о 4:00 непомітно зупинить сервіси, доки ви цього не помітите.

Щоденні команди

Усі щоденні операції зводяться до п’яти команд, які виконують із каталогу проєкту.

docker compose up -d        # create and start; idempotent, recreates only what changed
docker compose ps           # status, ports, and health of this project's containers
docker compose logs -f miniflux   # follow one service's logs; --tail 100 for recent history
docker compose pull && docker compose up -d   # upgrade to the pinned tags
docker compose down         # stop and remove containers and the network

up -d безпечно виконувати повторно: команда порівнює файл із фактичним станом і змінює лише конфігурацію або образ тих сервісів, які відрізняються. Пара команд для оновлення завантажує те, на що тепер вказують ваші зафіксовані теги: patch-релізи для postgres:16-alpine, а для точного pin-значення — нічого, доки ви його не зміните. У цьому й полягає призначення точного pin-значення. Після оновлень старі образи накопичуються. Звільніть місце на диску за допомогою docker image prune -f.

Тепер про деструктивну команду — це важливо: docker compose down безпечна, оскільки контейнери й мережа одноразові, а дані зберігаються у volume. docker compose down -v також видаляє іменовані volume. Це ваша база даних: вона зникне миттєво, без запиту на підтвердження та без можливості скасування. Прапорець -v призначений для видалення експериментальних середовищ. У стеку з реальними даними ставтеся до нього так само, як до rm -rf. У /var/lib/docker/volumes/ немає кошика.

Щоб одноразово відкрити shell усередині запущеного контейнера, використовуйте docker compose exec db psql -U miniflux для входу в контейнер бази даних, а docker compose exec miniflux sh — щоб отримати shell у контейнері застосунку.

Де фактично зберігаються ваші дані

Іменовані томи отримують префікс проєкту, тому db-data у каталозі з назвою miniflux стає miniflux_db-data:

docker volume ls
docker volume inspect miniflux_db-data

У виводі inspect є потрібний рядок:

"Mountpoint": "/var/lib/docker/volumes/miniflux_db-data/_data"

Цей каталог містить базу даних, належить root у файловій системі хоста та зберігається після down, оновлень і повторного створення контейнерів. Саме цей каталог мають охоплювати резервні копії.

Резервне копіювання іменованого тому

Стандартний підхід — одноразовий контейнер, який підключає том у режимі лише для читання поруч із каталогом хоста, а потім створює архів tar:

docker run --rm \
  -v miniflux_db-data:/data:ro \
  -v "$PWD":/backup \
  alpine:3.22 tar czf /backup/miniflux-db-$(date +%F).tar.gz -C /data .

Нічого не потрібно встановлювати, після завершення не залишається запущених процесів. Відновлення виконується у зворотному порядку: tar xzf у новий порожній том із тими самими підключеннями, але в зворотному напрямку.

Для баз даних є важливе обмеження: створення архіву tar з активного каталогу даних Postgres може зафіксувати стан під час запису, і база не запуститься належним чином. Або docker compose stop на кілька секунд, потрібних для виконання tar, або, що краще, створіть логічний дамп. Він узгоджений за своєю структурою:

docker compose exec -T db pg_dump -U miniflux miniflux | gzip > miniflux-$(date +%F).sql.gz

Параметр -T вимикає псевдотермінал, який Compose виділяє за замовчуванням. Передавання виводу дампа через TTY може його пошкодити. Додайте один із цих варіантів до cron і скопіюйте результат за межі VPS. Резервна копія на тому самому диску, що й дані, які вона захищає, є лише копією, а не резервною копією. У посібнику Nextcloud описано повну процедуру планування на основі саме цих двох підходів.

Режими відмови та повідомлення, які ви побачите

permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock означає, що ваш користувач ще не входить до групи docker або поточний сеанс було відкрито до додавання до цієї групи. id показує ефективні групи користувача; newgrp docker застосовує зміни до поточної оболонки, а вихід із системи та повторний вхід застосовують їх для всіх сеансів.

Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running? означає іншу проблему: сам daemon не працює. sudo systemctl status docker і sudo journalctl -u docker -n 50 показують причину. На VPS типовою причиною є переповнений диск, тому спочатку виконайте df -h /var/lib/docker.

Bind for 127.0.0.1:8080 failed: port is already allocated означає, що інший контейнер уже опублікував цей порт хоста. docker ps показує, який саме; зазвичай це застарілий контейнер після експерименту з docker run кілька тижнів тому. Якщо docker ps не повертає результатів, порт зайнятий процесом не з Docker: sudo ss -tlnp | grep 8080 покаже його.

yaml: line 14: did not find expected key означає помилку відступів у вказаному рядку або безпосередньо над ним. Файли Compose використовують YAML: відступи мають становити два пробіли, дозволені лише пробіли, а символ табуляції в будь-якому місці робить файл недійсним. docker compose config перевіряє файл, нічого не запускаючи. Запускайте цю перевірку після кожного редагування — це проста корисна звичка.

Неочікувана поведінка ufw полягає в тому, що команда взагалі не виводить помилки. Саме це робить ситуацію небезпечною: розгортання завершується успішно, ufw status має правильний вигляд, але сканування портів ззовні все одно виявляє вашу базу даних. Ще раз перегляньте наведений вище розділ про порти, перевірте кожен запис ports: на відсутність префікса 127.0.0.1: і підтвердьте результат з іншого комп’ютера за допомогою curl http://your-vps-ip:8080. Потрібною відповіддю є connection refused.

Далі посібник з Traefik перетворює цей один стек на багато застосунків за однією HTTPS-точкою входу, а що варто розміщувати на власному сервері у 2026 році — це список для послідовного розгортання через нього. Коли запрацює кілька таких стеків і кожен матиме власну форму входу, self-hosted SSO-сервер на кшталт Authentik об’єднає їх в один обліковий запис за тим самим проксі.

Ігровий сервер, наприклад сервер Minecraft на VPS, — зручний перший Compose-проєкт для практики. Якщо ви віддаєте перевагу тому, чим користуєтеся щодня, openGym, self-hosted трекер тренувань — це невеликий стек, зафіксований на git tag, а не на image tag. Перед реєстрацією першого passkey для нього потрібно налаштувати TLS попереду. Фотографії зазвичай є першими даними, які люди хочуть забрати з чужої хмари, а порівняння PhotoPrism та Immich допоможе визначити мінімальний обсяг RAM і процедуру резервного копіювання, на які ви погоджуєтеся, перш ніж прив’язувати volume до будь-якого з них. Коли двох сервісів стане замало, розгортання AFFiNE як робочого простору у стилі Notion використовує ті самі підходи вже для чотирьох контейнерів і добре перевіряє, чи стали наведені вище pinned tags, healthchecks і named volumes звичними.

FAQ

Чому я отримую помилку "permission denied while trying to connect to the Docker daemon socket"?

Ваш користувач не входить до групи docker, або його додали після початку поточного сеансу. Членство в групі застосовується лише під час входу в систему. Виконайте sudo usermod -aG docker $USER, потім newgrp docker, або вийдіть із системи та увійдіть знову. Перевірте результат за допомогою id. Група надає доступ до вузла, еквівалентний root, тому додавайте до неї лише користувачів, яким ви надали б sudo.

Чи видаляє docker compose down мої дані?

Звичайна команда docker compose down не видаляє дані. Вона видаляє контейнери та мережу проєкту. Іменовані томи зберігаються, а наступна команда up -d повторно підключає їх. Команда docker compose down -v є деструктивною: вона видаляє іменовані томи, а отже й вашу базу даних, без підтвердження та можливості скасування. Ніколи не виконуйте -v для стека з реальними даними, якщо у вас немає перевіреної резервної копії.

У чому різниця між docker-compose і docker compose?

docker-compose (дефіс) — старий спосіб виклику Compose. На старих серверах це зазвичай Compose v1 — оригінальна версія на Python, яку Docker оголосив такою, що досягла кінця життєвого циклу. Docker також досі публікує окремий двійковий файл docker-compose, створений на основі актуального коду Go, але не рекомендує його встановлення та підтримує лише для зворотної сумісності. Тому на нових серверах не використовуйте його. docker compose (пробіл) — поточний Compose: починаючи з v2 і з номером v5 з December 2025. Це плагін Go для Docker CLI, який встановлюється як docker-compose-plugin з apt-репозиторію Docker. Команди та YAML майже повністю сумісні. Тому коли старий підручник містить docker-compose up, введіть docker compose up.

Чому я можу підключитися до Docker-контейнера з інтернету, хоча ufw блокує порт?

Тому що Docker публікує порти за допомогою правил DNAT у ланцюжку PREROUTING iptables. Переписані пакети проходять шляхом FORWARD через власні ланцюжки Docker і не потрапляють до ланцюжка INPUT, де застосовуються правила ufw. Тому ufw deny 8080 не впливає на опублікований порт контейнера. Виправте це на джерелі: публікуйте порт до 127.0.0.1: і надавайте доступ до сервісів через reverse proxy.

Чи використовувати іменований том або bind mount?

Використовуйте іменовані томи для даних, з якими працює лише контейнер, особливо для баз даних. Docker встановлює належність, яку очікує образ, тому права доступу працюють без додаткових налаштувань. Використовуйте bind mount для файлів, з якими ви також працюєте на хості: конфігурацій, які редагуєте, медіафайлів, які завантажуєте, і всього, чий шлях має бути очевидним. Якщо контейнер не запускається та повертає permission denied для bind mount, спочатку перевірте невідповідність UID між хостом і контейнером.