Антипаттерн: пароли в переменных окружения docker-compose
Если в вашем docker-compose.yml где-то есть строка вида POSTGRES_PASSWORD: SuperSecret123, а сам файл лежит в git — у вас уже утечка, вопрос только в масштабе. Не важно, что репозиторий приватный и «только для своих»: пароль давно разошёлся по локальным клонам, CI-логам и, возможно, бэкапам. Разберём, почему это работает именно так, а не «ну мы же удалим строку потом», и как перейти на нормальную схему за один вечер.
Содержание
Как это обычно выглядит в реальном проекте
Классика: команда разворачивает сервис, кто-то один раз прописывает пароль прямо в конфиге — «чтобы все видели рабочую конфигурацию и не путались, у кого какой .env». Выглядит это примерно так:
# docker-compose.yml
services:
db:
image: postgres:16
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: SuperSecret123
POSTGRES_DB: app_production
api:
image: myregistry/api:latest
environment:
DATABASE_URL: postgresql://app:SuperSecret123@db:5432/app_production
JWT_SECRET: a8f5f167f44f4964e6c998dee827110c
STRIPE_SECRET_KEY: sk_live_51H8x2KJ9vB3nR7qLmP0oXyZaBcDeFgHi
depends_on:
- db
Файл коммитится: git add docker-compose.yml && git commit -m "add production config". Дальше он пушится в общий репозиторий — часто на GitHub/GitLab, часто с доступом у пяти-десяти человек, включая бывших подрядчиков и ботов CI. Выглядит удобно: клонировал репозиторий, поднял docker compose up -d — и всё сразу работает, без «а где взять пароль». Именно эта сиюминутная удобность и есть ловушка: удобство разработки куплено ценой постоянной утечки продакшен-секретов.
Часто к этому добавляется вторая ошибка — тот же файл с реальным паролем используют и для staging, и для локальной разработки, «чтобы не путать конфиги». В итоге один и тот же POSTGRES_PASSWORD открыт всем, кто хоть раз клонировал репозиторий ради локального теста.
Секрет остаётся в истории git навсегда
Это первая и самая недооценённая проблема. Git — система контроля версий, а не редактор: он не «перезаписывает» файл, а хранит цепочку снапшотов. Если вы удалите строку с паролем и закоммитите новую версию файла — старый коммит с паролем в открытом виде никуда не денется. Он остаётся в .git/objects и доступен любому, у кого есть клон репозитория:
# пароль давно "удалён" из текущей версии файла,
# но он всё ещё здесь:
git log --all -p -- docker-compose.yml | grep -A2 -B2 PASSWORD
# ещё нагляднее — поиск по всей истории репозитория
git log --all -S "SuperSecret123" --source --oneline
Вторая команда покажет коммит, в котором эта строка появилась, даже если в HEAD её давно нет. Любой разработчик, у которого когда-либо был доступ к репозиторию (включая уволенных сотрудников с локальным клоном на своём ноутбуке), может достать пароль из истории через год после «удаления».
Хуже, если репозиторий по ошибке стал публичным — например, кто-то форкнул приватный проект в личный аккаунт, или организация переключила видимость репозитория при реорганизации. GitHub индексирует публичные репозитории, и существуют автоматические боты, которые круглосуточно сканируют публичные коммиты именно на такие паттерны (PASSWORD, SECRET_KEY, sk_live_, AKIA и подобные). Ключ вида sk_live_... из примера выше — это формат реального Stripe secret key, и такие боты находят и используют подобные строки за считаные минуты после публикации.
Единственный по-настоящему надёжный способ убрать секрет из истории — переписать историю целиком (git filter-repo или BFG Repo-Cleaner) и заставить всех, у кого есть клон, синхронизироваться заново. Но это лечение симптома: сам пароль к этому моменту нужно считать скомпрометированным и обязательно сменить — переписывание истории его не «отзывает», оно просто чистит будущие клоны.
# пример полной очистки истории от секретов (BFG Repo-Cleaner)
# запускать на голом клоне, не на рабочей копии
git clone --mirror git@github.com:org/repo.git
bfg --replace-text passwords.txt repo.git
cd repo.git
git reflog expire --expire=now --all
git gc --prune=now --aggressive
git push --force
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать VPSdocker inspect показывает все переменные окружения в открытом виде
Даже если бы пароль каким-то чудом не попал в git, хранить его в секции environment: контейнера — само по себе плохая идея. Docker не шифрует и не скрывает переменные окружения контейнера — они читаются любым, у кого есть доступ к Docker API на этом хосте:
docker inspect api | grep -A20 '"Env"'
Вывод покажет реальные значения открытым текстом:
"Env": [
"DATABASE_URL=postgresql://app:SuperSecret123@db:5432/app_production",
"JWT_SECRET=a8f5f167f44f4964e6c998dee827110c",
"STRIPE_SECRET_KEY=sk_live_51H8x2KJ9vB3nR7qLmP0oXyZaBcDeFgHi",
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin"
]
Это не баг Docker, а его нормальное поведение — переменные окружения по определению доступны процессу и всем, кто может с ним взаимодействовать через API. Проблема в том, кто ещё имеет доступ к этому API:
- любой пользователь в группе
dockerна хосте (а это фактически root-эквивалент, не только «просмотр»); - любой другой контейнер, если у него по ошибке смонтирован
/var/run/docker.sock(частая практика для «удобных» дашбордов и CI-раннеров внутри Docker); - любой человек, зашедший на сервер по SSH с sudo-правами, включая временных подрядчиков;
- система мониторинга/APM, если она собирает метаданные контейнеров через Docker API — переменные окружения нередко попадают в её базу как часть метаданных.
То же самое видно ещё проще — через docker exec, без всякого API:
docker exec api env | grep -i secret
Если у вас docker-compose.yml подключён через Portainer или похожую панель — там переменные окружения контейнера тоже показываются в интерфейсе открытым текстом, в разделе описания контейнера. Это ещё одна точка, где секрет виден человеку, которому вы, может быть, не планировали показывать пароль от боевой базы.
Логи — ещё один канал утечки секретов
Третий путь утечки менее очевиден, но встречается регулярно: отладочный вывод. Разработчики нередко логируют переменные окружения при старте приложения — чтобы убедиться, что конфигурация подхватилась правильно:
# частая практика "для отладки", которая остаётся в проде
import os
print("Starting with config:", dict(os.environ))
// то же самое на Node.js
console.log('ENV:', process.env);
Дальше эти логи улетают в docker logs, попадают в централизованную систему логирования (ELK, Loki, Datadog), а оттуда — в индекс, который живёт месяцами и к которому обычно имеет доступ куда больше людей, чем к самому серверу: саппорт, аналитики, иногда внешние подрядчики по мониторингу. Один print для отладки на этапе разработки — и пароль от продакшен-базы лежит в логах с retention 90 дней, доступных половине компании через веб-интерфейс.
То же самое случается при падении приложения: многие фреймворки при необработанном исключении печатают полный контекст процесса, включая переменные окружения, прямо в stack trace — и этот trace тоже попадает в логи или, ещё хуже, отправляется во внешний сервис отслеживания ошибок (Sentry и подобные) как есть, без фильтрации чувствительных полей.
Правильный подход: .env вне git
Минимальный и почти бесплатный по трудозатратам шаг — вынести значения в .env-файл и явно исключить его из git. Сам docker-compose.yml при этом ссылается на переменные, но не содержит значений:
# docker-compose.yml — коммитится в git, секретов не содержит
services:
db:
image: postgres:16
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
api:
image: myregistry/api:latest
environment:
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
JWT_SECRET: ${JWT_SECRET}
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY}
Реальные значения — в .env рядом с файлом (Docker Compose подхватывает его автоматически, если он лежит в той же директории):
# .env — НЕ коммитится, лежит только на сервере/у разработчика локально
POSTGRES_USER=app
POSTGRES_PASSWORD=Xk9$mQ2vR8pL4nT6wY1z
POSTGRES_DB=app_production
JWT_SECRET=f3e9c7a1b5d8046e2c9f7a3b1d5e8c04
STRIPE_SECRET_KEY=sk_live_51H8x2KJ9vB3nR7qLmP0oXyZaBcDeFgHi
Обязательно добавьте .env в .gitignore — причём сделать это нужно ДО первого коммита с реальными значениями, иначе смысл теряется:
# .gitignore
.env
.env.*
!.env.example
*.env.local
docker-compose.override.yml
Обратите внимание на !.env.example — это исключение из исключения. В репозиторий вместо реального .env кладут его безопасный шаблон — .env.example, с теми же ключами, но без настоящих значений:
# .env.example — коммитится, показывает структуру, без реальных значений
POSTGRES_USER=app
POSTGRES_PASSWORD=change_me
POSTGRES_DB=app_production
JWT_SECRET=generate_with_openssl_rand_hex_32
STRIPE_SECRET_KEY=sk_live_your_key_here
Так новый разработчик видит, какие переменные нужны приложению, копирует файл (cp .env.example .env) и заполняет своими значениями — не гадая и не выпрашивая пароль в чате. Полезная привычка — генерировать секреты не «на глаз», а честной случайностью:
# сгенерировать случайный секрет достаточной длины
openssl rand -hex 32
Если у вас уже есть .env в истории git — его тоже нужно вычистить тем же способом, что описан выше для docker-compose.yml, и сменить все значения, которые в нём были: сам факт удаления файла из репозитория не отменяет того, что он уже побывал в истории.
Docker secrets и env_file как следующий шаг
.env-файл закрывает утечку через git, но не через docker inspect — переменные всё равно видны в открытом виде любому с доступом к Docker API на хосте. Для Docker Swarm есть встроенный механизм secrets, который решает именно это: секрет монтируется в контейнер как файл в /run/secrets/, а не как переменная окружения, и не светится в docker inspect:
# docker-compose.yml для Swarm-стека
services:
db:
image: postgres:16
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets:
- db_password
secrets:
db_password:
external: true
Сам секрет создаётся отдельной командой и хранится в зашифрованном виде в Raft-логе Swarm, а не в конфиге:
echo "Xk9\$mQ2vR8pL4nT6wY1z" | docker secret create db_password -
docker service update --secret-add db_password api
Обратите внимание на суффикс _FILE в имени переменной — многие официальные образы (postgres, mysql, redis и другие) поддерживают такой вариант «из коробки»: вместо значения приложение читает путь к файлу и достаёт секрет оттуда. Если ваш образ такого не поддерживает, секрет всё равно можно прочитать вручную при старте контейнера скриптом-обёрткой, который читает файл и экспортирует переменную только в памяти процесса.
Если вы не используете Swarm (обычный docker compose без кластера), минимальный шаг — env_file: вместо инлайновых значений в environment:. Это не решает проблему docker inspect (Compose всё равно разворачивает файл в переменные окружения контейнера), но убирает секреты из самого docker-compose.yml, что уже закрывает риск утечки через git и через беглый просмотр конфига кем попало:
services:
db:
image: postgres:16
env_file:
- .env.db
Для команд, где секретов становится много и их нужно централизованно ротировать и аудировать — кто, когда и к какому паролю обращался — стоит присмотреться к отдельному секрет-менеджеру. Мы разбирали развёртывание HashiCorp Vault на VPS отдельно: это более тяжёлое решение, чем .env, но оно снимает саму проблему «пароль лежит файлом где-то на диске» — секреты выдаются приложению по запросу и на ограниченное время. Более подробно про механизм Docker secrets — в отдельном разборе управления паролями через Docker secrets. Если же вам ближе более лёгкий self-hosted секрет-менеджер с UI — есть готовый Compose-файл для Infisical, который тоже решает задачу центрального хранения секретов без развёртывания полноценного Vault.
Что проверить в уже существующем проекте
Если у вас уже есть работающий проект и вы не уверены, есть ли в истории секреты — прежде чем паниковать, стоит спокойно всё проверить по шагам:
| Шаг | Команда | Что смотрим | |||
|---|---|---|---|---|---|
| 1. Секреты в текущих файлах | `grep -rn "PASSWORD\ | SECRET\ | _KEY" docker-compose*.yml` | Инлайновые значения вместо ${VAR} | |
| 2. Секреты в истории git | `git log --all -p -- '*.yml' \ | grep -i password` | Утечки в старых коммитах | ||
| 3. .env в git | `git ls-files | grep '\.env$'` | Файл не должен там быть | ||
| 4. Переменные в работающих контейнерах | docker inspect $(docker ps -q) --format '{{.Name}} {{.Config.Env}}' | Что видно через API прямо сейчас | |||
| 5. Секреты в логах | `docker logs api 2>&1 \ | grep -iE "password | secret | token"` | Утечка через отладочный вывод |
Если находите совпадения на шагах 2–3 — считайте эти значения скомпрометированными и меняйте их, а не просто удаляйте файл. Подробнее о том, какие ещё практики стоит пересмотреть в конфигурации контейнеров, — в отдельном материале про best practices безопасности Docker, а про то, как не наступить на грабли с утечкой через журналы — в разборе лучших практик логирования Docker.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать VPSНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Если репозиторий приватный, разве это правда проблема?
Да. Приватность репозитория не отменяет истории git — доступ к ней есть у всех текущих и части бывших участников проекта, у CI-систем, у сервисов резервного копирования кода. И приватный репозиторий может стать публичным по ошибке — это происходит регулярно при реорганизациях, миграциях между платформами и настройках форков.
Достаточно ли просто удалить строку с паролем и закоммитить заново?
Нет. Старый коммит с паролем останется доступен через git log, git show <commit> и клоны репозитория у других участников. Нужно либо переписывать историю целиком (git filter-repo, BFG), либо — что проще и надёжнее — считать пароль скомпрометированным и просто сменить его.
.env-файл полностью решает проблему?
Он решает утечку через git и облегчает ротацию секретов, но не убирает их видимость через docker inspect и docker exec — с точки зрения Docker API переменная окружения остаётся переменной окружения независимо от того, откуда Compose её взял. Для полного закрытия этого канала нужны Docker secrets или внешний секрет-менеджер.
Нужен ли Vault маленькому проекту с одним сервером?
Не обязательно — это оверинжиниринг для проекта с парой сервисов. Для старта достаточно .env вне git и аккуратной ротации при подозрении на утечку. Vault и подобные системы имеют смысл, когда секретов и серверов становится много и нужен централизованный аудит доступа.
Что делать, если секрет уже утёк — например, ключ Stripe засветился в публичном репозитории?
Первым делом отозвать/перевыпустить сам ключ через панель управления сервиса (это единственное реальное «обнуление» утечки), и только потом чистить историю git и настраивать .env. Пока старый ключ активен, чистка репозитория не защищает от того, что он уже кем-то скопирован.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →