SSD Nodes Learn Hosting plans →
Посібники Matt ConnorВід Matt Connor · Оновлено 2026-08-27

Docker Compose на VPS: встановлення та перший стек

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

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

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

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

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

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

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

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

sudo apt remove -y docker.io docker-compose docker-compose-v2 docker-doc 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 v2.x.x підтверджує, що встановлено плагін, а не застарілий бінарний файл v1. Команда 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 означає, що переходи на нову major-версію відбуватимуться одразу після їх випуску upstream, а не тоді, коли ви це запланували. Для PostgreSQL це не теоретичний ризик: неочікуваний перехід з 16 на 17 залишає контейнер у циклі аварійних перезапусків через несумісний каталог даних, оскільки оновлення major-версії Postgres потребує dump і restore, а не перезапуску.

Фіксуйте щонайменше major-версію (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": адреса хоста, порт хоста, порт контейнера. У більшості tutorial використовується "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 999, каталог на хості належить UID 1000, і застосунок завершується під час запуску з повідомленням permission denied у журналі. Іменовані томи здебільшого усувають цей клас помилок, але дані зберігаються за шляхом, яким керує Docker; це описано нижче.

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

${POSTGRES_PASSWORD} не читається з вашої shell-сесії. Compose підставляє його значення з файлу .env, розташованого поруч із compose.yml. Створіть його:

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 запускає контейнери знову навіть після ручної зупинки, що рідко відповідає очікуваній поведінці. Без політики перезапуску перезавантаження після оновлення kernel о 4-й ранку непомітно зупинить сервіси, доки ви цього не помітите.

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

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

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. Очікуваною відповіддю має бути відмова в підключенні.

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

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

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 v1, окремий двійковий файл Python, підтримку якого припинено у 2023 році. Його не слід встановлювати на нових серверах. docker compose (із пробілом) — це Compose v2, плагін 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 на хості та в контейнері.