MAATRIX / Блог / Антипаттерн: пароли в переменных окружения docker-compose

Антипаттерн: пароли в переменных окружения docker-compose

MAATRIX

Если в вашем 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, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать VPS

docker 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-filesgrep '\.env$'`Файл не должен там быть
4. Переменные в работающих контейнерахdocker inspect $(docker ps -q) --format '{{.Name}} {{.Config.Env}}'Что видно через API прямо сейчас
5. Секреты в логах`docker logs api 2>&1 \grep -iE "passwordsecrettoken"`Утечка через отладочный вывод

Если находите совпадения на шагах 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 ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →