Docker Compose на Ubuntu 24.04: установка и настройка
Установите Docker Engine и Compose v2 на Ubuntu 24.04. Разверните стек из двух сервисов, настройте healthcheck, тома и решите проблему обхода правил 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, который удаляет базу данных без запроса подтверждения.
Предварительные требования: чистая KVM VPS с Ubuntu 24.04, пользователь с правами sudo и не менее одного гигабайта оперативной памяти. Существующая установка Docker также подойдет; в первом разделе описано, что именно нужно удалить.
Установка из репозитория Docker, а не Ubuntu
Перед выполнением первой команды следует избежать двух типичных ошибок. Пакет docker.io из репозитория Ubuntu работает, но отстает от релизов Docker и не поддерживает структуру плагинов, на которую рассчитано остальное ПО. Отдельный бинарный файл docker-compose (с дефисом в названии) — это Compose v1 на языке Python; его поддержка прекращена в 2023 году, и именно из-за него перестают работать старые руководства. Современный Compose — это docker compose (через пробел), CLI-плагин, который устанавливается из того же репозитория, что и сам движок.
Если что-то из этого уже установлено в системе, сначала удалите эти пакеты, включая docker-compose-v2 (пакет плагина от Ubuntu), чтобы все компоненты были получены из одного источника:
sudo apt remove -y docker.io docker-compose docker-compose-v2 docker-doc podman-docker containerd runcPackage '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 в поисковых системах:
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-адрес назначения пакета на внутренний IP контейнера до фильтрации. В результате пакет следует по пути FORWARD и никогда не попадает в INPUT, где работают ваши правила ufw. sudo ufw deny 8080 сообщает об успехе, ufw status показывает, что порт закрыт, но сервис при этом доступен всему Интернету. Ваш файрвол не сломан; он намеренно обходится. В статье Почему 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} не считывается из вашей оболочки; 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 должна быть добавлена до первого коммита: 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 этого не делает: она удаляет контейнеры и сеть проекта, но именованные тома сохраняются, и следующая команда 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 из репозитория Docker apt. Команды и 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 mounts используйте для файлов, с которыми вы работаете и на хосте: конфигурации, которые вы редактируете, загружаемые медиафайлы и всё, путь к чему должен быть очевиден. Если контейнер не запускается с ошибкой permission denied при использовании bind mount, в первую очередь проверьте несовпадение UID между хостом и контейнером.