# Файловые секреты: что умеет каждое приложение Дата: 2026-07-25. Статус: справка по итогам проверки исходников (не план работ). Проверялись **закреплённые в наших compose-файлах версии**, не `main`. Источник — код парсинга конфига в репозиториях приложений; доки использовались как подтверждение, а не как основание. Из этой справки выросли задачи `secrets-file-*` в беклоге, по одной на приложение. ## Зачем Секреты стоят прямыми значениями в `environment:` docker-compose. Такие переменные читает любой член группы `docker`, любой процесс с доступом к `/proc//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 `_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: если переменная пуста, читается `_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 L95–L143](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 L100–141](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` открытым текстом**, то есть секрет всё равно оседает на хостовом диске в `/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 L99–121](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/`, то есть **тот же способ доставки**, что и `*_FILE`. Приложение, умеющее только переменную окружения, из `/run/secrets` не прочитает: ограничение в приложении, а не в доставке. На матрицу выше не влияет. Что даёт: секрет объявляется на сервис, а не монтируется каталогом. У miniflux сейчас `{{ secrets_dir }}:/secrets:ro` висит на обоих сервисах, и postgres видит все шесть файлов, включая секреты OIDC, хотя нужен ему один пароль. Улучшение поверх образца, а не замена ему. `uid`/`gid`/`mode` вне swarm игнорируются — права остаются с хост-файла, их ставит роль `secrets`.