SSD Nodes Learn Hosting plans →
Руководства Matt ConnorАвтор: Matt Connor

Docker Compose на Ubuntu 24.04: настройка и запуск

Установите Docker Engine и плагин Compose на Ubuntu 24.04. Разверните стек из двух сервисов, настройте healthcheck, тома и решите проблему обхода правил 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, который удаляет базу данных без запроса подтверждения.

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

Установка из репозитория Docker, а не Ubuntu

Перед выполнением первой команды следует избежать двух типичных ошибок. Пакет docker.io из репозитория Ubuntu работает, но отстает от релизов Docker и не поддерживает структуру плагинов, на которую рассчитано остальное ПО. Команда docker-compose с дефисом — это путь, который предлагают устаревшие руководства. На старых серверах эта команда обычно вызывает Compose v1, написанный на Python; Docker объявил его устаревшим, и именно из-за него такие руководства перестают работать. Docker по-прежнему выпускает отдельный бинарный файл docker-compose, собранный на современном языке Go, но на официальной странице установки этот способ не рекомендуется и поддерживается только для обратной совместимости. Современный Compose — это docker compose (через пробел), плагин CLI, который устанавливается из того же репозитория, что и сам движок.

Если что-то из этого уже установлено, сначала удалите это, включая 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 в поисковых системах:

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 в Docker — это реальная альтернатива, при которой сам демон запускается от имени вашего непривилегированного пользователя. У этого есть свои издержки: для портов ниже 1024 требуется дополнительная настройка, сетевой трафик проходит через прослойку в пространстве пользователя с заметными накладными расходами, а некоторые образы работают некорректно без реальных прав 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 требуют дампа и восстановления, а не просто перезапуска.

Фиксируйте как минимум мажорную версию (postgres:16-alpine соответствует патч-релизам 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 показывает, что порт закрыт, но сервис при этом отвечает всему интернету. Ваш файрвол не сломан; он обходится по замыслу разработчиков Docker. Почему Docker обходит ufw и как фильтровать трафик контейнеров по-настоящему подробно описывает этот механизм и решение DOCKER-USER для портов, которые должны оставаться публичными.

Привычка, которая решает эту проблему: привязывайте опубликованные порты к 127.0.0.1, если у вас нет веских причин поступать иначе, и ставьте обратный прокси перед всем, что должно быть доступно извне. Именно это и строится в руководстве по обратному прокси 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, но только если ваша оболочка еще не определила эту переменную: значение, экспортированное в окружении оболочки, имеет приоритет над файлом .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. Используйте шестнадцатеричный формат, а не base64, намеренно: этот пароль попадает в строку подключения DATABASE_URL, а символы /, + и =, которые могут появиться в base64, нарушают парсинг URL. Это приводит к ошибке аутентификации, а не синтаксической ошибке, и стоит потраченного вечера. Строка .gitignore добавляется до первого коммита: compose-файл безопасно публиковать и версионировать, файл .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 воскрешает контейнеры даже после ручной остановки, что редко является желаемым поведением. Без политики перезапуска перезагрузка после обновления ядра в 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 можно безопасно запускать многократно: она сравнивает конфигурацию с текущим состоянием системы и затрагивает только те сервисы, для которых изменились настройки или образы. Пара команд для обновления загружает то, на что указывают ваши закрепленные теги: патч-релизы для postgres:16-alpine, а для точных версий — ничего, пока вы не измените их вручную, в чем и заключается смысл. После обновлений остаются старые образы; освободите место на диске с помощью docker image prune -f.

Теперь о деструктивной команде, которую следует использовать с осторожностью: docker compose down безопасна, так как контейнеры и сеть являются временными, а ваши данные хранятся в томах. docker compose down -v удаляет и именованные тома. Это означает, что ваша база данных будет мгновенно удалена без запроса подтверждения и возможности отмены. Флаг -v предназначен для удаления экспериментальных сред; если стек содержит реальные данные, относитесь к этой команде так же, как к rm -rf. В /var/lib/docker/volumes/ нет корзины.

Для запуска разовой оболочки внутри работающего контейнера: docker compose exec db psql -U miniflux открывает консоль базы данных, а docker compose exec miniflux sh предоставляет доступ к оболочке приложения.

Где фактически хранятся ваши данные

Именованные тома получают префикс проекта, поэтому 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 в новый пустой том с обратным порядком монтирования.

Важное замечание для баз данных: архивирование работающей директории данных 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?: другая проблема — сам демон не запущен. 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 году станет отличным подспорьем для наполнения вашего сервера. Когда таких стеков станет много и у каждого появится своя форма входа, самохостинг SSO-сервера, например Authentik позволит объединить их под одной учётной записью за тем же прокси.

Игровой сервер, например Minecraft-сервер на VPS, — удобный первый проект в Compose для практики. Если вы предпочитаете учиться на том, чем пользуетесь каждый день, openGym, трекер тренировок с самостоятельным размещением, — это небольшой стек, закреплённый за 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 этого не делает: она удаляет контейнеры и сеть проекта, но именованные тома (named volumes) сохраняются, и при следующем выполнении 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 (с декабря 2025 года — v5), плагин 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.

Что лучше использовать: именованный том (named volume) или bind mount?

Именованные тома подходят для данных, с которыми работает только контейнер (особенно для баз данных), так как Docker сам устанавливает владельца, ожидаемого образом, и права доступа работают корректно. Bind mounts используйте для файлов, которыми вы управляете и с хоста: конфигурации, которые вы редактируете, загружаемые медиафайлы и все, путь к чему должен быть очевиден. Если контейнер не запускается с ошибкой permission denied при использовании bind mount, в первую очередь проверьте несовпадение UID между хостом и контейнером.