Docker Compose: использование нескольких файлов
Узнайте, как Docker Compose объединяет файлы конфигурации, почему порт остается занятым при переопределении и как правильно использовать include для dev и prod сред.
Как Compose работает с несколькими файлами
Docker Compose может собрать один проект из нескольких файлов. Он считывает их в порядке получения и объединяет в единую модель, поэтому при возникновении конфликтов значений приоритет имеет файл, указанный последним. Для этого из командной строки используются два механизма: файл переопределения, который Compose загружает автоматически, и флаг -f, который вы передаете вручную. Третий механизм находится внутри самого файла — это элемент include, который работает иначе.
Объединение не является простой перезаписью. Отображения (mappings) объединяются по ключам, последовательности (sequences) дополняются, а небольшой набор полей заменяется целиком. Именно в этой разнице кроются неожиданности, а список ports — это то, на чем спотыкаются почти все.
Все описанное ниже предполагает использование Compose v2 или более поздней версии, то есть плагина docker compose, а не старого скрипта docker-compose. Текущие релизы имеют версию v5, и плагин работает с ними так же. Выполните docker compose version для проверки. Если вы еще не написали файл Compose, начните с руководства по основам Docker Compose и вернитесь сюда.
Файл переопределения, который Compose загружает автоматически
Запустите docker compose up без флага -f, и Compose выполнит поиск файла compose.yaml или docker-compose.yaml в текущем рабочем каталоге, а затем в родительских каталогах. Если файл переопределения находится рядом с базовым файлом, Compose загружает его вторым автоматически.
ls compose.yaml compose.override.yaml
docker compose up -dПри наличии обоих файлов результат будет таким же, как если бы вы указали их вручную.
docker compose -f compose.yaml -f compose.override.yaml up -dCompose распознает имена compose.override.yaml, compose.override.yml, а также устаревшие docker-compose.override.yml и docker-compose.override.yaml. Любое другое имя, например compose.dev.yaml, загружается только в том случае, если вы укажете его с помощью -f.
Как только вы передаете хотя бы один -f, автоматическая загрузка прекращается. docker compose -f compose.yaml up считывает только указанный файл и игнорирует переопределение; именно на этом свойстве строится шаблон для сред разработки и эксплуатации, описанный далее в этом руководстве.
На сервере это работает в обе стороны. Файл переопределений, оставленный в каталоге развёртывания, загружается каждой командой bare docker compose, выполненной из этого каталога, включая команду, которую запускает cron job. Поэтому production stack может начать монтировать исходный каталог через bind mount, хотя никто не собирался включать его в поставку. Выполните docker compose config после каждого развёртывания и проверьте полученный вывод. Если развёртывание выполняется без участия оператора, проверка полезна только в том случае, если вы получите уведомление об ошибке. Для этого нужен канал отправки уведомлений, например self-hosted ntfy server, в который cron job или systemd OnFailure unit может отправить сообщение.
Порядок использования -f и разрешение относительных путей
Compose формирует конфигурацию в том порядке, в котором вы передаете файлы; последующие файлы переопределяют и дополняют предыдущие. Обработка идет слева направо, приоритет у последнего файла.
docker compose -f compose.yaml -f compose.prod.yaml config
docker compose -f compose.yaml -f compose.prod.yaml up -dДля каждой команды в этом проекте должен использоваться один и тот же список файлов. Запустите up с двумя файлами, а logs — с одним, и вы обратитесь к другой объединённой модели. Это быстрый способ получить сервис, которого Compose не находит. Риск выше для стека, обновления которого выполняются разовыми командами. Например, это может быть шаг миграции базы данных в самостоятельно размещённой системе поддержки Chatwoot. Если docker compose run запустить с неправильным списком файлов, команда незаметно обратится к другой модели, отличной от той, которую уже используют ваши сервисы. Вместо этого задайте список один раз с помощью переменной окружения COMPOSE_FILE.
export COMPOSE_FILE=compose.yaml:compose.prod.yaml
docker compose config
docker compose up -dРазделителем является : в Linux, а COMPOSE_PATH_SEPARATOR позволяет его изменить. Переменную COMPOSE_FILE также можно прописать в файле .env проекта, что сделает её частью репозитория, а не истории вашей оболочки. Любое значение, явно указанное в командной строке, имеет приоритет над переменной окружения.
Теперь правило, которое нарушает работу bind mounts. При использовании нескольких файлов с -f все относительные пути во всех этих файлах разрешаются относительно директории первого файла, а не того файла, в котором они указаны. Если написать ./data:/var/lib/postgresql/data внутри deploy/prod/compose.prod.yaml, Compose всё равно будет искать ./data рядом с базовым файлом. В результате Docker создаст пустую директорию по неверному пути, и контейнер запустится с пустым содержимым, что выглядит как потеря данных, хотя это не так. Используйте --project-directory, чтобы задать базовый путь самостоятельно, или используйте include, который разрешает пути каждого файла относительно его собственной директории.
Имя проекта берется из той же базовой директории, поэтому изменение первого файла в списке может привести к переименованию проекта. Переименованный проект означает новые имена контейнеров и томов, при этом старые тома останутся на диске под старым именем. Зафиксируйте имя проекта с помощью параметра верхнего уровня name: в базовом файле.
name: myappКакие поля объединяются, а какие заменяются
Compose выполняет объединение на основе типа значения, а не имени поля.
- Поля с одиночными значениями заменяются.
image,command,entrypointиmem_limitполностью принимают значение из файла, который считывается позже. Вы не можете добавить аргумент кcommand, так как переопределение полностью перезаписывает строку. - Словари (mappings) объединяются по ключам.
environment,labels,volumesиdevicesсохраняют все ключи из обоих файлов, при этом в случае совпадения ключей приоритет отдается файлу, который считывается позже. Дляenvironmentиlabelsключом является имя переменной или метки. Дляvolumesиdevicesключом является путь внутри контейнера. - Списки (sequences) объединяются путем добавления.
dns,dns_search,expose,tmpfsиexternal_linksконкатенируются. Базовый файл, содержащийexpose: ["3000"], при объединении с переопределением, содержащим["4000", "5000"], даст в результате["3000", "4000", "5000"].
Четыре типа списков имеют идентификационный ключ, поэтому записи, совпадающие по этому ключу, объединяются, а не добавляются. volumes, secrets и configs сопоставляются по target. ports сопоставляется по комбинации ip, target, published и protocol.
Прочитайте это правило ports дважды, так как именно здесь чаще всего возникают ошибки. Две записи портов считаются одной и той же записью только в том случае, если все четыре параметра совпадают. Измените любой из них, и Compose воспримет это как второй, независимый порт, поэтому сохранит обе записи.
Почему порт остается опубликованным после переопределения
Базовый файл, который публикует сервис на всех интерфейсах:
services:
web:
image: nginx:1.27
ports:
- "8080:80"Переопределение, написанное для привязки только к localhost, так как перед ним будет стоять reverse proxy:
services:
web:
ports:
- "127.0.0.1:8080:80"Проверьте результат, прежде чем считать, что всё сработало.
docker compose -f compose.yaml -f compose.prod.yaml configОбе записи присутствуют в выводе. Часть ip различается: 0.0.0.0 против 127.0.0.1, поэтому для механизма слияния это два разных порта, и публичная привязка, которую вы пытались удалить, всё ещё остается в модели. В Docker это важнее, чем где-либо, так как опубликованный порт записывается в iptables до ваших правил firewall. Механизм описан в почему опубликованные порты Docker обходят ufw.
Существует два способа исправления. Явный способ — использование тега !override, который заменяет весь атрибут целиком и пропускает правила слияния:
services:
web:
ports: !override
- "127.0.0.1:8080:80"!override требует Docker Compose версии 2.24.4 или новее. Портативный способ не требует никаких тегов: исключите ports из базового файла полностью и объявляйте его только в файлах, специфичных для окружения. Если нечего объединять, то нечему и «утекать». Этот шаблон используется в приведенном ниже рабочем примере.
Удаление значения из базового набора файлов
!reset удаляет атрибут, возвращая его к значению по умолчанию или к null. Команда принимает значение и игнорирует его, поэтому укажите что-либо корректное и пустое.
services:
web:
ports: !reset []
environment:
DEBUG: !reset null!reset работает в Compose v2.18.0 или новее, появившись за несколько релизов до !override. Используйте его, когда базовый файл нельзя редактировать, например, если это фрагмент от поставщика. Опубликованный upstream-стек — именно такой случай: Compose-файл для самостоятельно развернутого рабочего пространства AFFiNE объявляет четыре контейнера, которые вы не создавали, а !reset позволяет очистить один атрибут у одного из них без необходимости форкать файл и брать на себя задачу по его отслеживанию.
include, для стеков, собранных из частей
include подгружает другое приложение Compose в вашу модель. Это элемент верхнего уровня, а не флаг.
include:
- path: ../commons/compose.yamlКаждый путь в include загружается как отдельная модель приложения Compose со своей собственной директорией проекта, поэтому относительные пути внутри этого файла разрешаются относительно директории самого файла. В этом заключается главное отличие от -f, и именно поэтому include является подходящим инструментом, когда фрагмент находится в другой папке или другом репозитории. Это типичный вид стороннего стека, который писали не вы: многосервисный файл Compose, стоящий за самостоятельно развернутой установкой Authentik SSO, может находиться в своей собственной директории с сохранением всех относительных путей, в то время как ваш файл остается посвященным только вашим собственным сервисам.
Длинная форма поддерживает дополнительные параметры.
include:
- path:
- ../monitoring/compose.yaml
- ../monitoring/compose.vps.yaml
project_directory: ../monitoring
env_file: ../monitoring/.envpath принимает список, и эти файлы объединяются по стандартным правилам перед тем, как результат будет добавлен в вашу модель. project_directory задает базовый путь, используемый для разрешения относительных путей во включенном файле. env_file указывает файл, из которого включенный файл Compose берет значения по умолчанию для интерполяции. По умолчанию используется .env в директории проекта самого файла, при этом переменные вашего локального проекта по-прежнему имеют приоритет, что позволяет переопределить одну из его переменных без редактирования самого фрагмента. include требует Compose версии v2.20.0 или новее. Эти же параметры подходят для добавления одноконтейнерного дополнения к стеку, который вы уже используете, например Halcyon, который меняет интерфейс библиотеки Jellyfin на стиль видеопроката 90-х: его файл сохраняет собственный тег образа и собственный env_file, поэтому обновление этого дополнения никогда не потребует внесения правок в файл, в котором находится ваш медиа-стек.
Документация Docker гласит, что ресурс, объявленный как в вашем файле, так и во включенном файле, вызывает ошибку конфликта. Загрузчик в текущем релизе ведет себя иначе: по состоянию на версию Compose v5.6.1 (проверено по исходному коду загрузчика, поставляемому на 2026-10-27), он объединяет два определения, при этом значения из вашего файла имеют приоритет в случае конфликта, вместо того чтобы прерывать работу с ошибкой. Поэтому не стоит рассчитывать на то, что дублирующееся имя вызовет явный сбой. Чтобы изменить что-то, объявленное во включенном файле, внесите изменение в compose.override.yaml: переопределение применяется к уже собранной модели, поэтому изменение является явным и не зависит от того, как конкретный релиз обрабатывает конфликт. Эта привычка наиболее полезна при работе со стеком, чей исходный файл перезаписывается при каждом обновлении, например, с многоконтейнерными фотосерверами, рассмотренными в PhotoPrism против Immich, где привязка к localhost или дополнительный том должны находиться в вашем переопределении, а не в файле, который будет заменен при следующем обновлении.
Кратко: include объединяет отдельные приложения, -f накладывает конфигурацию на одно приложение.
Разделение dev и prod на одном VPS
Данная схема реализуется с помощью трех файлов. Базовый файл описывает общие параметры и не открывает порты.
name: myapp
services:
app:
image: ghcr.io/example/app:1.4.2
environment:
DATABASE_URL: postgres://app:${POSTGRES_PASSWORD}@db:5432/app
LOG_LEVEL: info
depends_on:
db:
condition: service_healthy
restart: unless-stopped
db:
image: postgres:16
environment:
POSTGRES_USER: app
POSTGRES_DB: app
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d app"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
volumes:
db_data:Условие depends_on заставляет приложение дожидаться готовности базы данных, а не просто факта запуска контейнера, подробнее см. в healthchecks и условия depends_on. Переменная POSTGRES_PASSWORD подставляется из файла .env, который не должен попадать в git. Безопасные варианты описаны в env-файлы и секреты Compose.
Далее идет compose.override.yaml, который Compose загружает автоматически. Это файл для разработчика.
services:
app:
build: .
command: npm run dev
environment:
LOG_LEVEL: debug
ports:
- "3000:3000"
volumes:
- ./src:/app/src
db:
ports:
- "127.0.0.1:5432:5432"На локальной машине простая команда docker compose up объединяет эти два файла. command заменяет значение образа по умолчанию, так как это одиночное значение. LOG_LEVEL заменяет info, поскольку environment выполняет слияние по ключам. Bind mount и два открытых порта — это дополнительные параметры, а порт базы данных привязан к localhost, чтобы PostgreSQL не был доступен всем в локальной сети.
Последний файл — compose.prod.yaml. Его имя не входит в список стандартных, поэтому он не загрузится случайно.
services:
app:
ports:
- "127.0.0.1:8000:3000"
deploy:
resources:
limits:
memory: 512MНа VPS вы указываете оба файла, и явное указание имен исключает использование файла переопределения.
docker compose -f compose.yaml -f compose.prod.yaml config
docker compose -f compose.yaml -f compose.prod.yaml up -d
docker compose -f compose.yaml -f compose.prod.yaml psКоманда ps должна показать, что оба сервиса запущены, а db выводит (healthy). Поскольку вы передали -f, файл compose.override.yaml не был прочитан. Поэтому команда для разработки, bind mount исходного кода и публичный порт 3000 не влияют на production, даже если файл находится в той же директории. Порт 8000 доступен только через localhost и готов к работе с прокси: см. запуск нескольких приложений за Traefik при добавлении второго сервиса.
Установите COMPOSE_FILE=compose.yaml:compose.prod.yaml в файле .env на сервере, и остальные команды снова станут обычными вызовами docker compose logs -f app.
Стек из одного сервиса строится по такому же принципу, так как трекер тренировок openGym должен отвечать по TLS через прокси до регистрации первого passkey, а базовый файл без секции ports предотвращает случайное открытие портов, которые могут конфликтовать с прокси.
Изучите объединенную модель перед развертыванием
docker compose config выводит полностью объединенную и интерполированную модель. Это не предварительный просмотр. Это точные входные данные, которые будет использовать Compose, поэтому, если вывод не совпадает с вашими ожиданиями, верным является именно вывод.
docker compose -f compose.yaml -f compose.prod.yaml config
docker compose -f compose.yaml -f compose.prod.yaml config --no-interpolate
docker compose -f compose.yaml -f compose.prod.yaml config --services--no-interpolate оставляет ${VAR} в нераскрытом виде. Используйте этот флаг перед тем, как вставлять вывод куда-либо, так как обычный config выводит каждый разрешенный секрет в открытом виде. --services выводит только имена сервисов, что является быстрым способом подтвердить, что include подтянул именно то, что вы ожидали.
Режимы сбоев и их признаки
no configuration file provided: not found. Compose не нашел данных для чтения. Вы находитесь вне директории проекта, или COMPOSE_FILE указывает на несуществующий путь. Compose ищет базовый файл по умолчанию в родительских директориях, но не ищет файл, который вы указали самостоятельно, ни в каких других местах.
WARN[0000] The "POSTGRES_PASSWORD" variable is not set. Defaulting to a blank string. Интерполяция выполняется относительно файла .env проекта и переменных окружения оболочки, при этом директорией проекта считается директория первого файла -f. Развертывание из директории, отличной от той, где находится .env, приводит к появлению этого предупреждения, после чего база данных отклоняет все соединения.
Ваши изменения в файле переопределения не отображаются в docker compose config. Либо вы передали флаг -f, который отключает автоматическую загрузку переопределений, либо Compose нашел compose.yaml в родительской директории, а ваш файл переопределения находится не рядом с ним. Запуск docker compose config без дополнительных аргументов покажет, какую именно модель собирает Compose.
Bind mount пуст, и Docker создал директорию, которую вы не запрашивали. Относительный путь был разрешен относительно директории первого файла. Исправьте путь, передайте --project-directory или переместите фрагмент за include.
Контейнеры возвращаются с новыми именами, а том выглядит пустым. Имя проекта изменилось, так как оно определяется директорией первого файла. Добавьте параметр name: верхнего уровня в базовый файл, и именование перестанет меняться. Старый том по-прежнему существует под старым префиксом, и docker volume ls покажет его.
Порт, который вы удалили в файле переопределения, все еще открыт. Слияние ports добавило значения, вместо того чтобы заменить их. Проверьте конфигурацию с помощью docker compose config, затем используйте !override или вынесите ports из базового файла.
FAQ
Загружает ли Compose файл compose.override.yaml автоматически?
Да, если вы запускаете docker compose без флага -f. Compose ищет compose.yaml или docker-compose.yaml в текущем рабочем каталоге и его родительских директориях. Если рядом находится файл переопределения, он загружается вторым. Распознаются имена compose.override.yaml, compose.override.yml, docker-compose.override.yml и docker-compose.override.yaml. Передача любого -f отключает это поведение, поэтому docker compose -f compose.yaml up считывает только один файл.
В каком порядке объединяются несколько файлов -f?
Слева направо. Compose выстраивает конфигурацию в порядке предоставления файлов, при этом каждый последующий файл дополняет или переопределяет предыдущие. Таким образом, последний файл в строке имеет приоритет при возникновении конфликтов. Для каждой команды в рамках проекта необходимо использовать один и тот же список файлов, для чего и предназначен COMPOSE_FILE=compose.yaml:compose.prod.yaml.
Почему порт всё ещё опубликован после того, как я его переопределил?
Потому что записи ports идентифицируются по всей совокупности ip, target, published и protocol. Переопределение 127.0.0.1:8080:80 для базовой конфигурации 8080:80 отличается в части ip, поэтому Compose рассматривает это как второй порт и сохраняет оба. Запустите docker compose config, чтобы увидеть обе записи. Используйте ports: !override в Compose версии 2.24.4 или новее, либо не указывайте ports в базовом файле, чтобы не возникало конфликтов при слиянии.
В чём разница между include и -f?
-f накладывает несколько файлов на одно приложение, при этом все относительные пути в каждом файле разрешаются относительно директории первого файла. include подтягивает отдельное приложение Compose, и каждый включённый путь сохраняет свою собственную директорию проекта, поэтому относительные пути разрешаются относительно него самого. Используйте -f для слоев окружения вашего собственного стека, а include — для фрагмента, который поддерживается отдельно. include требует Compose версии 2.20.0 или новее.
Как удалить значение, заданное в базовом файле?
Используйте тег !reset в Compose версии 2.18.0 или новее. Укажите ports: !reset [] или MY_VAR: !reset null в переопределяющем файле, и атрибут вернётся к своему значению по умолчанию или к null. Значение, которое вы присваиваете тегу, обязательно, но игнорируется. Если вы хотите заменить атрибут, а не очистить его, используйте !override; эта возможность требует версии 2.24.4 или новее.