backlog: secrets-env-to-file разложена по приложениям

- Шесть задач `secrets-file-*` вместо одной: outline, wakapi, authelia,
  gramps (средний), gitea, tududi (низкий, выигрыш частичный). Wanderer
  отпал — файловых секретов не умеет ни meilisearch, ни pocketbase.
- Матрица механизмов со ссылками на код уехала в
  docs/drafts/secrets-file-support.md, родитель — на кладбище.
This commit is contained in:
av
2026-07-25 12:25:50 +03:00
parent f9a8c64377
commit fbd0a66f44
14 changed files with 384 additions and 100 deletions
+2
View File
@@ -0,0 +1,2 @@
# Кладбище беклога
- 2026-07-25 `secrets-env-to-file` — Секреты приложений через `*_FILE`, а не inline `environment:`. Причина: разложена по приложениям на secrets-file-outline/-wakapi/-authelia/-gramps/-gitea/-tududi; матрица поддержки уехала в docs/drafts/secrets-file-support.md, wanderer отпал — файловых секретов не умеет. Был приоритет: средний.
+6 -1
View File
@@ -25,7 +25,6 @@ _(sec-ревью)_ — три независимых security-ревью на м
## Средний
- [Секреты приложений через `*_FILE`, а не inline `environment:`](secrets-env-to-file.md) — outline/wakapi/tududi/gitea/gramps/tuwunel держат секреты в env; видны в `docker inspect` и `/proc` _(sec-ревью)_
- [Захардить мониторинг-стек (netdata / dozzle / goaccess)](harden-monitoring-stack.md) — netdata/dozzle: лишние привилегии + docker.sock + доверие Remote-User в общей сети; socket-proxy + monitoring_network _(sec-ревью)_
- [Ограничить эндпоинт `/metrics` Miniflux](miniflux-metrics-restrict.md) — `METRICS_ALLOWED_NETWORKS=0.0.0.0/0` + прокси без ограничения пути отдаёт метрики публично _(sec-ревью)_
- [Закрыть calibre-web forward-auth и проверить дефолтные креды](calibre-forward-auth.md) — домен без forward_auth, защита на встроенной авторизации с дефолтом `admin/admin123` _(sec-ревью)_
@@ -39,6 +38,10 @@ _(sec-ревью)_ — три независимых security-ревью на м
- [Composable-роль `backup`](ansible-backup-role.md) — бэкап — самый чистый шов для extraction (одинаков у всех, различается только список targets) _(ансибл-ревью)_
- [Локальная сборка образов вместо Yandex Container Registry](local-image-build.md) — homepage/transcriber/remembos тянут образы из cr.yandex, ради `docker login` в vault лежит OAuth-токен всего аккаунта; роль `app_image` уже обкатана в umbar
- [Вывести из оборота секреты с широкими правами](narrow-secret-scopes.md) — один `yc_oauth_token` открывает весь аккаунт Yandex Cloud; после инцидента 24 июля цена утечки одной переменной должна быть ограничена одним сервисом
- [outline: секреты из файлов](secrets-file-outline.md) — умеет generic `<NAME>_FILE` для всех пяти секретов; единственное приложение, где выносится вообще всё, включая пароль БД
- [wakapi: секреты из файлов](secrets-file-wakapi.md) — salt, SMTP и OIDC выносятся либо через generic `_FILE`, либо через `config.yml`; для OIDC второй путь надёжнее из-за бага с `=` в значении
- [authelia: секреты из файлов](secrets-file-authelia.md) — пять полей закрывает `_FILE`, jwks-ключ и client secret — только template-фильтр; заодно четыре client secret можно перевести в хэш и перестать считать секретами
- [gramps: секретный ключ из файла](secrets-file-gramps.md) — entrypoint читает `/app/secret/secret`, если переменная не задана, и этот путь уже смонтирован — самая дешёвая правка из всей серии
## Низкий
@@ -50,3 +53,5 @@ _(sec-ревью)_ — три независимых security-ревью на м
- [Фоновая зачистка стиля и конфигурации Ansible](ansible-style-nits.md) — sudoers.d, профиль ansible-lint, `ansible.cfg`, кавычки, `cache_valid_time` _(ансибл-ревью)_
- [Logrotate для логов бэкапов](backup-logs-logrotate.md) — логи `backup-all.py`/restic копятся без ротации _(tududi)_
- [[idea] Где хранить пароль ansible-vault](vault-password-storage.md) — пароль открытым текстом рядом с репо, `ansible.cfg` подставляет его в любую ansible-команду; вариант — доставать из KeePassXC
- [gitea: пароль SMTP из файла](secrets-file-gitea.md) — умеет `GITEA__mailer__PASSWD__FILE`, но пишет значение открытым текстом в `app.ini` — выигрыш только против `docker inspect`
- [tududi: секреты в смонтированный `.env`](secrets-file-tududi.md) — файловых секретов не умеет вовсе; единственный путь — смонтировать `.env` под `dotenv`, чтобы значения не попадали в `Config.Env`
+1 -1
View File
@@ -39,4 +39,4 @@ transcriber и remembos. Радиус прав несопоставим с за
Связано: group_vars/all/secrets.yml, playbook-homepage.yml,
playbook-transcriber.yml, playbook-remembos.yml,
[vault-password-storage](vault-password-storage.md),
[secrets-env-to-file](secrets-env-to-file.md).
[справка по файловым секретам](../drafts/secrets-file-support.md).
@@ -28,7 +28,7 @@ miniflux деплоится молча, а остальные — нет.
- **apprise** — `server.template.cfg`: токен бота Telegram, SMTP
- **remembos** — `config.template.toml`: токены memos и Telegram
- **outline, tududi, wakapi, wanderer, gramps, gitea** — секреты в
`environment:` docker-compose, см. [secrets-env-to-file](secrets-env-to-file.md)
`environment:` docker-compose, см. серию задач `secrets-file-*`
Чисто: miniflux (образец), memos, calibre, tuwunel, dashboard, caddy, netdata,
dozzle, goaccess, rssbridge, homepage.
@@ -48,8 +48,8 @@ dozzle, goaccess, rssbridge, homepage.
для файлов, состоящих из одних секретов, слабее.
- **Структурный вынос** — секрет живёт в отдельном файле, который рендерит роль
`secrets` под `no_log`, а основной файл ссылается на путь. Тогда дифф основного
файла остаётся полезным. Это [secrets-env-to-file](secrets-env-to-file.md), но
она про `environment:` и не покрывает конфиги.
файла остаётся полезным. Это серия `secrets-file-*`, но она про `environment:`
и не покрывает конфиги.
- **Убрать `--diff` из `inv pl`** или выставить глобальный `ANSIBLE_NO_LOG`
режет обзор на всех задачах разом ради десятка проблемных. Не годится.
@@ -60,7 +60,8 @@ dozzle, goaccess, rssbridge, homepage.
2. Где файл смешанный (compose, конфиг authelia) — вернуться и вынести секреты в
отдельные файлы, сняв `no_log` с основного: он нужен ради диффа, который сейчас
и теряется. Что именно умеет каждое приложение — матрица в
[secrets-env-to-file](secrets-env-to-file.md), сверенная по исходникам. Для
[справке](../drafts/secrets-file-support.md), сверенная по исходникам, и
задачи `secrets-file-*` по одной на приложение. Для
authelia (конфиг на тысячу строк, дифф там самый ценный) ответ такой: пять
полей закрываются `AUTHELIA_..._FILE`, оставшиеся два — только
template-фильтром, а четыре `client_secret` штатно хранятся хэшем и тогда
-91
View File
@@ -1,91 +0,0 @@
# Секреты приложений через `*_FILE`, а не inline `environment:`
**Приоритет:** средний
miniflux сделан правильно — роль `secrets` кладёт каждую переменную vault в
отдельный файл, каталог монтируется `:ro`, а compose ссылается на `*_FILE`
(`files/miniflux/docker-compose.template.yml`). Остальные держат секреты прямыми
значениями в `environment:`: их читает любой член группы `docker`, любой процесс
с доступом к `/proc/<pid>/environ`, и они попадают в `docker inspect`.
tuwunel из списка убран: в его compose секретов нет (сверено 2026-07-25). memos и
calibre тоже чистые.
## Матрица поддержки (проверено по исходникам, 2026-07-25)
Проверялись **закреплённые в compose версии**, не `main`.
| Приложение | Секрет | Файл | Механизм |
|---|---|---|---|
| **outline** `1.9.2` | `SECRET_KEY`, `UTILS_SECRET`, OIDC, SMTP | да | generic: `environment.ts` оборачивает `process.env` в Proxy, пустая переменная читается из `<NAME>_FILE`. С v1.7.0 |
| | пароль postgres | да, с оговоркой | `DATABASE_URL_FILE` выносит URL целиком; чтобы вынести только пароль — раздельные `DATABASE_HOST/PORT/NAME/USER` + `DATABASE_PASSWORD_FILE` (взаимоисключимы с `DATABASE_URL`) |
| | пароль сайдкара postgres | да | штатный `POSTGRES_PASSWORD_FILE` официального образа |
| **wakapi** `2.17.5` | salt, SMTP, OIDC | да | два пути: generic `_FILE` в `loadSecretFiles()` (с 2.17.2, в README список устарел) либо `config.yml` в `/app/config.yml` |
| **gitea** `1.27.0` | SMTP | да, частично | `GITEA__mailer__PASSWD__FILE`, работает для любого ключа `app.ini`, с 1.20 |
| **gramps** `26.7.0` | `GRAMPSWEB_SECRET_KEY` | да | entrypoint читает `/app/secret/secret`, если переменная не задана. **Путь уже смонтирован** в нашем compose |
| | SMTP | нет | обходной путь есть, но недокументированный: `config.cfg` исполняется Flask как Python, значит `EMAIL_HOST_PASSWORD = open(...).read()` сработает |
| **authelia** `4.39.20` | jwt-секрет, session, ключ хранилища, hmac OIDC, SMTP | да | `AUTHELIA_..._FILE` |
| | jwks-ключ, `client_secret` × 4 | нет через `_FILE` | `IsSecretKey()` отвергает ключи с `[]`. Покрывается template-фильтром: `X_AUTHELIA_CONFIG_FILTERS=template` + `{{ secret "путь" }}` (с 4.38) |
| **tududi** `1.2.4` | все четыре | **нет** | всё через `process.env`; ни `_FILE`, ни конфига, ни CLI |
| **wanderer** `0.18.3` | мастер-ключ meilisearch | **нет** | только CLI/env/инлайн в `config.toml`; апстрим отклонил запрос в 2023 |
| | ключ шифрования pocketbase | **нет** | собственная переменная wanderer, не штатный `--encryptionEnv` pocketbase; ENTRYPOINT в exec-форме, подстановку не сделать |
## Что из этого следует
Самое дешёвое — **gramps**: каталог `/app/secret` уже смонтирован, достаточно
записать туда значение и убрать переменную из `environment:`. Оба сервиса
(`gramps_app` и `gramps_celery`) читают тот же файл через общий entrypoint.
**Authelia** выгоднее переводить не на `_FILE`, а целиком на template-фильтр:
`_FILE` покрывает пять полей из семи, фильтр — все разом, и смешивать их в одном
поле нельзя (`errFmtSecretAlreadyDefined`). Отдельная находка: четыре
`client_secret` штатно хранятся **хэшем** `$pbkdf2-sha512$...` (у нас уже есть
`inv authelia-gen-secret-and-hash`), плейнтекст с 4.38 вызывает warning
валидатора. То есть эти поля можно вообще перестать считать секретами.
**Gitea** даёт частичный выигрыш: секрет уходит из `environment:`, `docker
inspect` и окружения процесса, но `environment-to-ini` пишет его открытым
текстом в `app.ini` на диске — открытая проблема апстрима (#35316).
**tududi и wanderer не умеют ничего.** Для них остаётся либо принять env, либо
перенести переменные в файл на диске: у tududi `backend/app.js` зовёт `dotenv`
при рабочей директории `/app/backend`, поэтому смонтированный туда `.env` уберёт
секреты хотя бы из `docker inspect` — в отличие от `env_file:`, где они попадают
в `Config.Env`. У wanderer доступен только `env_file:`, то есть косметика.
Попутная находка в закомментированном OIDC-блоке wakapi (ловушка на будущее, не
живой баг): имена `WAKAPI_OIDC_PROVIDER_*` устарели, в 2.17.5 разбирается
`WAKAPI_OIDC_PROVIDERS_(\d+)_(...)`; кроме того `renameEnvVars()` режет строку по
первому `=`, так что секрет с base64-паддингом через env поедет битым. Оба
довода — за `config.yml`.
## Docker secrets — разобрано, покрытия не расширяет
`secrets:` в compose вне swarm — это bind-mount файла в `/run/secrets/<name>`,
то есть **тот же способ доставки**, что и `*_FILE`. Приложение, умеющее только
переменную окружения, из `/run/secrets` не прочитает — ограничение в приложении,
а не в доставке. На таблицу выше не влияет.
Что даёт: секрет объявляется на сервис, а не монтируется каталогом. У miniflux
сейчас `{{ secrets_dir }}:/secrets:ro` висит на обоих сервисах, и postgres видит
все шесть файлов, включая секреты OIDC, хотя нужен ему один пароль. Улучшение
поверх образца, а не замена ему. `uid`/`gid`/`mode` вне swarm игнорируются —
права остаются с хост-файла, их ставит роль `secrets`.
## Границы задачи
Она про то, где секрет **лежит в рантайме** (env против файла). Про то, чтобы он
не печатался в терминал при деплое, — [no-secrets-in-playbook-output](no-secrets-in-playbook-output.md),
и она идёт первой: `no_log` дёшев, закрывает все приложения разом и не зависит от
этой матрицы. Для tududi и wanderer он вообще единственный ответ.
Матрица показала, что задача переросла в эпик: каждая строка — отдельное
приложение со своим механизмом, и каждое мерджится независимо и само по себе
даёт пользу. Договорились разбить по приложениям отдельным заходом (2026-07-25);
до этого задачу в работу не брать целиком.
Из sec-ревью (fable, 2026-07-13). Связка с
[service-users-docker-group](service-users-docker-group.md).
Связано: files/outline, files/wakapi, files/tududi, files/wanderer, files/gramps,
files/gitea, files/authelia, files/miniflux (образец), roles/secrets.
+32
View File
@@ -0,0 +1,32 @@
# authelia: секреты из файлов
**Приоритет:** Средний
В `configuration.template.yml` прямыми значениями стоят семь позиций:
jwt-секрет валидации, секрет сессий, ключ шифрования хранилища, hmac-секрет OIDC,
приватный jwks-ключ, четыре `client_secret` и пароль SMTP. Файл на тысячу строк —
дифф по нему самый ценный в репозитории, и именно его сейчас приходится глушить
целиком.
Пять полей закрываются переменными `AUTHELIA_..._FILE`. Оставшиеся два —
`jwks[].key` и `client_secret` клиентов — через `_FILE` невозможны в принципе:
валидатор отвергает ключи, содержащие `[]`. Их покрывает template-фильтр
(`X_AUTHELIA_CONFIG_FILTERS=template` + `{{ secret "путь" }}`). Механизмы
несовместимы в одном поле, поэтому проще выбрать фильтр — он берёт все семь
позиций разом, `_FILE` только пять.
Отдельная и более дешёвая половина задачи: четыре `client_secret` штатно хранятся
**хэшем** `$pbkdf2-sha512$...` — плейнтекст с 4.38 вызывает warning валидатора и
нужен только при `client_secret_jwt`, чего у наших клиентов нет. После перевода
эти поля перестают быть секретами вообще. Генератор уже есть —
`inv authelia-gen-secret-and-hash`, проверка — `inv authelia-validate-config`.
Готово, когда в отрендеренном конфиге не осталось секретов открытым текстом и
`no_log` с задачи снят — дифф конфига снова читаем.
Разбор по каждому полю, версии и ссылки на код:
[справка по файловым секретам](../drafts/secrets-file-support.md).
Из декомпозиции `secrets-env-to-file` (2026-07-25).
Связано: files/authelia, tasks.py (`authelia-*`), roles/secrets,
[no-secrets-in-playbook-output](no-secrets-in-playbook-output.md).
+24
View File
@@ -0,0 +1,24 @@
# gitea: пароль SMTP из файла
**Приоритет:** Низкий
Единственный секрет gitea в compose — пароль SMTP. Механизм есть:
`GITEA__mailer__PASSWD__FILE` работает для любого ключа `app.ini` начиная с 1.20,
у нас 1.27.0.
Приоритет низкий из-за оговорки, которая съедает половину пользы:
`environment-to-ini` **записывает содержимое файла в `app.ini` открытым текстом**,
то есть пароль всё равно оседает на диске в `<data_dir>/gitea/conf/app.ini`. Это
открытая проблема апстрима (#35316), исправления нет. Выигрыш реальный, но
частичный — секрет уходит из `environment:`, `docker inspect` и окружения
процесса, а с диска не уходит.
Готово, когда `docker inspect` контейнера gitea не показывает пароль SMTP. Что он
остался в `app.ini` — известное ограничение, а не незакрытая часть задачи.
Механизм и ссылки на код:
[справка по файловым секретам](../drafts/secrets-file-support.md).
Из декомпозиции `secrets-env-to-file` (2026-07-25).
Связано: files/gitea, roles/secrets,
[no-secrets-in-playbook-output](no-secrets-in-playbook-output.md).
+26
View File
@@ -0,0 +1,26 @@
# gramps: секретный ключ из файла
**Приоритет:** Средний
`GRAMPSWEB_SECRET_KEY` стоит прямым значением в `environment:`. Если переменную
не задавать, entrypoint образа читает ключ из файла `/app/secret/secret` — и этот
путь **уже смонтирован** в нашем compose как `gramps_secret`. То есть правка
сводится к «записать значение в файл и убрать переменную»: самая дешёвая из всей
серии. Entrypoint общий у `gramps_app` и `gramps_celery`, оба читают тот же файл,
расхождения не будет.
Пароль SMTP так не выносится: конфиг читается через `from_prefixed_env`, никакой
обработки `_FILE` нет. Обходной путь существует (Flask исполняет `config.cfg` как
Python, значит значение можно прочитать из файла прямо в нём), но он держится на
детали реализации и нигде не обещан — брать только если решим, что оно того
стоит. Пока пароль SMTP остаётся в env и закрывается `no_log`.
Готово, когда `docker inspect` контейнеров gramps не показывает секретный ключ
приложения.
Механизм и ссылки на код:
[справка по файловым секретам](../drafts/secrets-file-support.md).
Из декомпозиции `secrets-env-to-file` (2026-07-25).
Связано: files/gramps, roles/secrets,
[no-secrets-in-playbook-output](no-secrets-in-playbook-output.md).
+24
View File
@@ -0,0 +1,24 @@
# outline: секреты из файлов
**Приоритет:** Средний
Сейчас в `environment:` стоят прямыми значениями пароль postgres (внутри
`DATABASE_URL`), `SECRET_KEY`, `UTILS_SECRET`, секрет OIDC и пароль SMTP. Outline
`1.9.2` умеет generic-механизм: пустая переменная читается из `<NAME>_FILE`,
поэтому все пять закрываются по образцу miniflux — роль `secrets` + монтирование
каталога `:ro`.
Отдельный шаг внутри задачи — пароль БД: `DATABASE_URL_FILE` выносит **весь** URL
одним куском, и чтобы вынести именно пароль, нужен переход на раздельные
`DATABASE_HOST/PORT/NAME/USER` (они взаимоисключимы с `DATABASE_URL`). Заодно
сайдкар postgres переводится на штатный `POSTGRES_PASSWORD_FILE`.
Готово, когда `docker inspect` контейнеров outline и его postgres не показывает
ни одного секрета, а в compose-шаблоне не осталось jinja-переменных с секретами —
и, как следствие, дифф этого шаблона снова можно смотреть без `no_log`.
Механизм и ссылки на код: [справка по файловым секретам](../drafts/secrets-file-support.md).
Из декомпозиции `secrets-env-to-file` (2026-07-25).
Связано: files/outline, files/miniflux (образец), roles/secrets,
[no-secrets-in-playbook-output](no-secrets-in-playbook-output.md).
+27
View File
@@ -0,0 +1,27 @@
# tududi: секреты в смонтированный `.env`
**Приоритет:** Низкий
Tududi `1.2.4` не умеет файловых секретов вообще: session secret, секрет OIDC,
ключ LLM и пароль SMTP читаются напрямую из `process.env`, конфиг-файла и
CLI-опций нет. Проверено грепом по репозиторию на теге, не по докам.
Единственный доступный путь — не `env_file:` (при нём значения всё равно попадают
в `Config.Env`), а **смонтированный файл**: `backend/app.js` вызывает `dotenv`, а
рабочая директория контейнера — `/app/backend`, значит `.env` можно положить туда
томом. Тогда секреты не видны в `docker inspect`, но остаются в окружении
процесса и в `/proc/<pid>/environ`.
Приоритет низкий именно поэтому: механизм нештатный (держится на том, что
`dotenv` остаётся в prod-зависимостях образа), а закрывает только один вектор из
трёх. Если апстрим когда-нибудь добавит `_FILE` — задача переписывается на него.
Готово, когда `docker inspect` контейнера tududi не показывает ни одного из
четырёх секретов.
Разбор и ссылки на код:
[справка по файловым секретам](../drafts/secrets-file-support.md).
Из декомпозиции `secrets-env-to-file` (2026-07-25).
Связано: files/tududi,
[no-secrets-in-playbook-output](no-secrets-in-playbook-output.md).
+28
View File
@@ -0,0 +1,28 @@
# wakapi: секреты из файлов
**Приоритет:** Средний
Сейчас в `environment:` прямыми значениями стоят salt паролей и пароль SMTP,
а в закомментированном блоке — секрет OIDC. Wakapi `2.17.5` даёт два пути, и
выбрать надо один:
- **generic `_FILE`** — `loadSecretFiles()` разворачивает любую переменную с
таким суффиксом. Ближе к образцу miniflux, меньше движений.
- **`config.yml`**, смонтированный в `/app/config.yml` — покрывает те же три
поля декларативно.
Для OIDC-секрета второй путь надёжнее: `renameEnvVars()` режет значение по
первому `=`, то есть секрет с base64-паддингом через env приедет битым. Там же
при включении OIDC надо поправить имена переменных — в 2.17.5 разбирается
`WAKAPI_OIDC_PROVIDERS_0_*`, а у нас закомментирован устаревший вариант, который
просто молча игнорируется.
Готово, когда `docker inspect` контейнера wakapi не показывает ни salt, ни пароль
SMTP, а дифф шаблона можно смотреть без `no_log`.
Механизм, версии и ссылки на код:
[справка по файловым секретам](../drafts/secrets-file-support.md).
Из декомпозиции `secrets-env-to-file` (2026-07-25).
Связано: files/wakapi, files/miniflux (образец), roles/secrets,
[no-secrets-in-playbook-output](no-secrets-in-playbook-output.md).
+3 -1
View File
@@ -13,6 +13,8 @@ gitea и т.д.) фактически все имеют root — изоляци
тем — лучше через socket-proxy). За `primary_user` группу оставить для ручных
операций (`inv ssh`).
Из sec-ревью (fable, 2026-07-13). Усиливает риск из [secrets-env-to-file](secrets-env-to-file.md).
Из sec-ревью (fable, 2026-07-13). Усиливает риск из серии `secrets-file-*`
(секреты в `environment:` контейнеров): членство в группе `docker` — как раз тот
доступ, которым такие переменные и вычитываются.
Связано: все playbook-*.yml (owner_extra_groups), roles/owner, playbook-docker.yml.
+3 -2
View File
@@ -39,8 +39,9 @@ enforcement-слой применяет снаружи. Что смена хра
случайного дампа, от копии `~` в чужих руках, или от агента. Ответы ведут к разным
вариантам, а третий не решается на этом уровне вообще.
Смежное: [secrets-env-to-file](secrets-env-to-file.md) — тот же вопрос слоем ниже
(секреты в `environment:` контейнеров). Отдельно не заведена, но обсуждалась,
Смежное: серия `secrets-file-*` — тот же вопрос слоем ниже (секреты в
`environment:` контейнеров), разбор механизмов в
[справке](../drafts/secrets-file-support.md). Отдельно не заведена, но обсуждалась,
идея скоупинга: вернуть `group_vars/all/secrets.yml` в `vars_files`, оставив
`main.yml` в `group_vars` — это режет потолок утечки с 53 переменных до ~11, ценой
возврата boilerplate в плейбуки.
+203
View File
@@ -0,0 +1,203 @@
# Файловые секреты: что умеет каждое приложение
Дата: 2026-07-25. Статус: справка по итогам проверки исходников (не план работ).
Проверялись **закреплённые в наших compose-файлах версии**, не `main`. Источник —
код парсинга конфига в репозиториях приложений; доки использовались как
подтверждение, а не как основание. Из этой справки выросли задачи
`secrets-file-*` в беклоге, по одной на приложение.
## Зачем
Секреты стоят прямыми значениями в `environment:` docker-compose. Такие
переменные читает любой член группы `docker`, любой процесс с доступом к
`/proc/<pid>/environ`, и они попадают в `docker inspect`. Цель — там, где
приложение умеет, брать секрет из файла.
Образец — **miniflux**: роль `secrets` кладёт каждую переменную vault в отдельный
файл, каталог монтируется `:ro`, compose ссылается на `*_FILE`
(`files/miniflux/docker-compose.template.yml`).
Отдельная и более важная цель — чтобы секреты не печатались в терминал при
деплое — этой справкой **не** решается: там работает `no_log`, см. задачу
`no-secrets-in-playbook-output`. Для tududi и wanderer `no_log` — единственный
доступный ответ.
## Матрица
| Приложение | Секрет | Файл | Механизм |
|---|---|---|---|
| **outline** `1.9.2` | `SECRET_KEY`, `UTILS_SECRET`, OIDC, SMTP | да | generic `<NAME>_FILE` |
| | пароль postgres | да, с оговоркой | только вместе с переходом на раздельные `DATABASE_*` |
| | пароль сайдкара postgres | да | штатный `POSTGRES_PASSWORD_FILE` |
| **wakapi** `2.17.5` | salt, SMTP, OIDC | да | generic `_FILE` либо `config.yml` |
| **gitea** `1.27.0` | SMTP | да, частично | `GITEA__mailer__PASSWD__FILE`, но значение оседает в `app.ini` |
| **gramps** `26.7.0` | `GRAMPSWEB_SECRET_KEY` | да | файл `/app/secret/secret`, путь уже смонтирован |
| | SMTP | нет | только недокументированный трюк с `config.cfg` |
| **authelia** `4.39.20` | jwt, session, ключ хранилища, hmac OIDC, SMTP | да | `AUTHELIA_..._FILE` |
| | jwks-ключ, `client_secret` × 4 | нет через `_FILE` | только template-фильтр; секреты клиентов лучше хэшировать |
| **tududi** `1.2.4` | все четыре | **нет** | обходной путь — смонтированный `.env` |
| **wanderer** `0.18.3` | ключ meilisearch, ключ pocketbase | **нет** | ничего |
## Подробности по приложениям
### outline `1.9.2`
`server/utils/environment.ts` оборачивает `process.env` в Proxy: если переменная
пуста, читается `<NAME>_FILE` как путь к файлу, содержимое обрезается по
пробелам. Весь `server/env.ts` и плагины читают именно через него, поэтому
механизм покрывает **любую** переменную, включая OIDC-плагин.
- Появилось в v1.7.0 (PR [#11906](https://github.com/outline/outline/pull/11906),
merged 2026-03-30), в v1.9.0 переписано на ленивый Proxy (PR
[#12889](https://github.com/outline/outline/pull/12889)). Проверено бисекцией:
в v1.6.1 механизма нет, в v1.7.0 есть.
- Конвенция описана в
[.env.sample](https://github.com/outline/outline/blob/v1.9.2/.env.sample)
строки 4–18, включая правило приоритета: заданы обе — побеждает прямая
переменная.
- Пароль БД: `DATABASE_URL_FILE` выносит **весь** URL. Чтобы вынести только
пароль, нужны раздельные `DATABASE_HOST/PORT/NAME/USER` + `DATABASE_PASSWORD`,
они взаимоисключимы с `DATABASE_URL` через `@CannotUseWith`
([env.ts L95L143](https://github.com/outline/outline/blob/v1.9.2/server/env.ts)).
- Сайдкар `postgres:16.3` — штатный `POSTGRES_PASSWORD_FILE`.
### wakapi `2.17.5`
Два независимых пути.
- `loadSecretFiles()`
([config.go L829](https://github.com/muety/wakapi/blob/2.17.5/config/config.go))
— generic: проходит по всему окружению, для любой переменной с суффиксом
`_FILE` читает файл, `TrimSpace`, кладёт в базовое имя, файловую снимает.
Заданы обе — процесс падает с «both environment variables are set».
- Порядок в `Load()`: `loadSecretFiles()``renameEnvVars()``configor.Load`,
поэтому `_FILE` работает и для OIDC-переменных в configor-формате.
- История: PR [#679](https://github.com/muety/wakapi/pull/679) (релиз 2.12.1)
сделал это в `entrypoint.sh` для пяти переменных; коммит `a45732b2` (релиз
2.17.2, переход на distroless) перенёс логику в Go и сделал универсальной.
README (L92) перечисляет только три переменные — **список устарел**
относительно кода.
- `config.yml` покрывает все три поля: `security.password_salt`,
`security.oidc[]`, `mail.smtp.password`. Образ кладёт `config.default.yml` в
`/app/config.yml`, туда же монтируется свой. Env перекрывает значения из yaml.
Две ловушки в нашем **закомментированном** OIDC-блоке (не живой баг, но при
включении выстрелит):
1. Имена `WAKAPI_OIDC_PROVIDER_CLIENT_ID` устарели — в 2.17.5 разбирается
`WAKAPI_OIDC_PROVIDERS_(\d+)_([A-Z_]+)`, старые молча игнорируются.
2. `renameEnvVars()` разбирает строку как `strings.Split(e, "=")` и берёт
`parts[1]` — значение обрезается по первому `=`, то есть секрет с
base64-паддингом через env поедет битым.
Оба довода — за `config.yml` для OIDC-секрета.
### gitea `1.27.0`
`GITEA__mailer__PASSWD__FILE=/path` — механизм универсальный, работает для любого
ключа `app.ini`, хвостовой перевод строки обрезается
([config_env.go L100141](https://github.com/go-gitea/gitea/blob/v1.27.0/modules/setting/config_env.go),
PR [#24832](https://github.com/go-gitea/gitea/pull/24832), с 1.20).
Оговорка, которая режет пользу: `environment-to-ini` **записывает содержимое
файла в `app.ini` открытым текстом**, то есть секрет всё равно оседает на
хостовом диске в `<data_dir>/gitea/conf/app.ini`. Открытая проблема апстрима
[#35316](https://github.com/go-gitea/gitea/issues/35316) (ранее #25316), PR нет.
Выигрыш реальный, но частичный: уходит из `environment:`, `docker inspect` и
окружения процесса — не с диска.
### gramps `26.7.0` (gramps-web-api v3.18.0)
Если `GRAMPSWEB_SECRET_KEY` не задана, entrypoint образа читает ключ из файла
`/app/secret/secret` (и генерирует, если файла нет) —
[docker-entrypoint.sh](https://github.com/gramps-project/gramps-web-api/blob/v3.18.0/docker-entrypoint.sh).
**Этот путь у нас уже смонтирован** как `gramps_secret`. Entrypoint общий у
`gramps_app` и `gramps_celery` через YAML merge key, оба читают тот же файл.
SMTP-пароль файлового источника не имеет: конфиг читается через
`app.config.from_prefixed_env(prefix="GRAMPSWEB")`, никакой обработки `_FILE`
([app.py L99121](https://github.com/gramps-project/gramps-web-api/blob/v3.18.0/gramps_webapi/app.py)).
Обходной путь: `Dockerfile` задаёт `GRAMPS_API_CONFIG=/app/config/config.cfg`, а
`from_envvar()` во Flask — это `from_pyfile()`, то есть **config.cfg исполняется
как Python**, и `EMAIL_HOST_PASSWORD = open("/run/secrets/smtp").read().strip()`
сработает. Механизм рабочий, но держится на детали реализации Flask и нигде не
обещан.
### authelia `4.39.20`
`IsSecretKey()` возвращает `false` для любого ключа, содержащего `[]` (элемент
списка), и требует, чтобы ключ оканчивался на `key`/`secret`/`password`/`token`/
`certificate_chain`
([helpers.go](https://github.com/authelia/authelia/blob/v4.39.20/internal/configuration/helpers.go)).
Отсюда деление:
- **Умеют `_FILE`** (5 полей): `identity_validation.reset_password.jwt_secret`,
`session.secret`, `storage.encryption_key`,
`identity_providers.oidc.hmac_secret`, `notifier.smtp.password`.
- **Не умеют** (2 позиции): `identity_providers.oidc.jwks[].key` и
`client_secret` четырёх клиентов — оба содержат `[]`.
Для них есть [template-фильтр](https://www.authelia.com/configuration/methods/files/)
(`X_AUTHELIA_CONFIG_FILTERS=template`, с 4.38): `{{ secret "/path" | nindent 10 }}`.
Механизмы **несовместимы в одном поле** — если задано и `_FILE`, и значение в
конфиге, Authelia падает с `errFmtSecretAlreadyDefined`. Поэтому проще выбрать
один: фильтр покрывает все семь позиций, `_FILE` — только пять.
Отдельно про `client_secret`: штатная форма хранения — PHC-дайджест
`$pbkdf2-sha512$310000$...`, плейнтекст с 4.38 вызывает warning валидатора
(`errFmtOIDCClientInvalidSecretPlainText`). Плейнтекст обязателен только при
`client_secret_jwt` или симметричном шифровании JWT — у наших четырёх клиентов
(miniflux, wakapi, tududi — дефолт; outline — `access_token_signed_response_alg:
none`) ничего такого нет. То есть эти поля можно **перестать считать секретами**:
в конфиге хэш, сам секрет живёт в vault и в конфиге клиентского приложения.
Генератор уже есть — `inv authelia-gen-secret-and-hash`.
### tududi `1.2.4` — не умеет
Все четыре секрета читаются напрямую из `process.env`
([config.js L94, L40](https://github.com/chrisvel/tududi/blob/v1.2.4/backend/config/config.js),
[providerConfig.js L82](https://github.com/chrisvel/tududi/blob/v1.2.4/backend/modules/oidc/providerConfig.js),
[service.js L20](https://github.com/chrisvel/tududi/blob/v1.2.4/backend/modules/ai-assistant/service.js)).
Греп по репозиторию на теге даёт только `DB_FILE` и `FILE_UPLOAD_LIMIT_MB`; ни
`readFileSync` для секретов, ни конфиг-файла, ни CLI-опции нет. Собственная дока
по OIDC советует «consider Docker secrets» — это совет про хранение, читать файлы
приложение не умеет.
Обходной путь: `backend/app.js` вызывает `require('dotenv').config()`, рабочая
директория контейнера — `/app/backend`, значит смонтированный туда `.env`
подхватится. Это не per-secret файлы, но значения не попадают в `Config.Env`
в отличие от `env_file:`, где попадают. `dotenv` не перезаписывает уже
установленные переменные окружения.
### wanderer `0.18.3` — не умеет ничего
- `MEILI_MASTER_KEY` (meilisearch v1.20.0): только CLI `--master-key`, env или
инлайн в `config.toml`; `_FILE` нет
([option.rs](https://github.com/meilisearch/meilisearch/blob/v1.20.0/crates/meilisearch/src/option.rs)).
Апстрим отклонил запрос —
[discussion #201](https://github.com/orgs/meilisearch/discussions/201), 2023.
Клиентская сторона (`wanderer_db`, `wanderer_web`) тоже читает `os.Getenv`.
- `POCKETBASE_ENCRYPTION_KEY` — это **не** штатный механизм pocketbase
(`--encryptionEnv`), а собственная переменная wanderer, читается `os.Getenv` в
четырёх местах
([db/main.go](https://github.com/Flomp/wanderer/blob/v0.18.3/db/main.go)).
ENTRYPOINT в exec-форме, подстановку через шелл не сделать. Найденный в поиске
`POCKETBASE_ENCRYPTION_KEY_FILE` — фича стороннего образа
`adrianmusante/pocketbase`, к `flomp/wanderer-db` отношения не имеет.
Доступен только `env_file:`, где значения всё равно попадают в `Config.Env`, то
есть косметика. Задачи по wanderer не заведено.
## Docker secrets — покрытия не расширяет
`secrets:` в compose вне swarm — это bind-mount файла в `/run/secrets/<name>`, то
есть **тот же способ доставки**, что и `*_FILE`. Приложение, умеющее только
переменную окружения, из `/run/secrets` не прочитает: ограничение в приложении, а
не в доставке. На матрицу выше не влияет.
Что даёт: секрет объявляется на сервис, а не монтируется каталогом. У miniflux
сейчас `{{ secrets_dir }}:/secrets:ro` висит на обоих сервисах, и postgres видит
все шесть файлов, включая секреты OIDC, хотя нужен ему один пароль. Улучшение
поверх образца, а не замена ему. `uid`/`gid`/`mode` вне swarm игнорируются —
права остаются с хост-файла, их ставит роль `secrets`.