From 346cbc1e3b80a09254c5ddba3cfb891670f2bd98 Mon Sep 17 00:00:00 2001 From: bebricoOOOOOOf <234202278+bebricoOOOOOOf@users.noreply.github.com> Date: Fri, 21 Aug 2026 17:28:46 +0900 Subject: [PATCH 1/2] feat: support *_FILE env variables for secrets Any variable known to the config schema can be provided in a file by setting _FILE (e.g. APP_SECRET_FILE=/run/secrets/app_secret), the convention official postgres/mysql images use, so secrets can be mounted from Docker Compose `secrets:` instead of being kept in plaintext .env. Resolution happens in every process that reads env directly: env validation (api/scheduler/processor), prisma.config.ts (migrate deploy, db seed) and the rescue CLI. Setting both and _FILE aborts the startup, values are never logged. Co-Authored-By: Claude Opus 5 --- .env.sample | 7 +++ prisma.config.ts | 17 ++++++ prisma/seed/config.seed.ts | 4 ++ src/bin/cli/cli.ts | 4 ++ .../common-config/common-config.module.ts | 7 ++- src/common/utils/load-secrets-from-files.ts | 60 +++++++++++++++++++ 6 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 src/common/utils/load-secrets-from-files.ts diff --git a/.env.sample b/.env.sample index 55ce7e1b..7917140d 100644 --- a/.env.sample +++ b/.env.sample @@ -1,3 +1,10 @@ +### DOCKER SECRETS ### +# Any variable the panel validates on startup can be provided in a file instead of a value: +# point _FILE to a file that holds the value, e.g. +# APP_SECRET_FILE=/run/secrets/app_secret +# Handy with Docker Compose "secrets:", which mounts them to /run/secrets/. +# Setting both and _FILE aborts the startup. + ### APP ### APP_PORT=3000 METRICS_PORT=3001 diff --git a/prisma.config.ts b/prisma.config.ts index 99e43c7f..2bffd26f 100644 --- a/prisma.config.ts +++ b/prisma.config.ts @@ -1,8 +1,25 @@ import 'dotenv/config'; import type { PrismaConfig } from 'prisma'; +import { readFileSync } from 'node:fs'; import path from 'node:path'; +// Docker secrets: DATABASE_URL_FILE/DIRECT_URL_FILE point to a file holding the value. +// Inlined on purpose, only this file (not src/) is copied into the runtime image. +for (const key of ['DATABASE_URL', 'DIRECT_URL']) { + const filePath = process.env[`${key}_FILE`]; + + if (!filePath) { + continue; + } + + if (process.env[key]) { + throw new Error(`${key} and ${key}_FILE are both set. Remove one of them.`); + } + + process.env[key] = readFileSync(filePath, 'utf8').replace(/(\r?\n)+$/, ''); +} + if (!process.env.DIRECT_URL) { // eslint-disable-next-line no-console console.log('DIRECT_URL is not set, using DATABASE_URL'); diff --git a/prisma/seed/config.seed.ts b/prisma/seed/config.seed.ts index 3de2b278..df4187cf 100644 --- a/prisma/seed/config.seed.ts +++ b/prisma/seed/config.seed.ts @@ -7,7 +7,9 @@ import timezone from 'dayjs/plugin/timezone'; import utc from 'dayjs/plugin/utc'; import { Redis } from 'ioredis'; +import { configSchema } from '@common/config/app-config/config.schema'; import { getRedisConnectionOptions } from '@common/utils'; +import { loadSecretsFromFiles } from '@common/utils/load-secrets-from-files'; import { checkupExternalSquads, @@ -26,6 +28,8 @@ import { migrateSharedLists, } from './seeders'; +loadSecretsFromFiles(process.env, Object.keys(configSchema.shape)); + dayjs.extend(utc); dayjs.extend(relativeTime); dayjs.extend(timezone); diff --git a/src/bin/cli/cli.ts b/src/bin/cli/cli.ts index 344e164e..f5a4ada2 100644 --- a/src/bin/cli/cli.ts +++ b/src/bin/cli/cli.ts @@ -15,13 +15,17 @@ import timezone from 'dayjs/plugin/timezone'; import utc from 'dayjs/plugin/utc'; import Redis from 'ioredis'; +import { configSchema } from '@common/config/app-config/config.schema'; import { getRedisConnectionOptions } from '@common/utils'; import { generateNodeCert } from '@common/utils/certs'; import { encodeCertPayload } from '@common/utils/certs/encode-node-payload'; +import { loadSecretsFromFiles } from '@common/utils/load-secrets-from-files'; import { CACHE_KEYS } from '@libs/contracts/constants'; import { TResponseRuleEncryption } from '@modules/subscription-response-rules/types/response-rules.types'; +loadSecretsFromFiles(process.env, Object.keys(configSchema.shape)); + dayjs.extend(utc); dayjs.extend(relativeTime); dayjs.extend(timezone); diff --git a/src/common/config/common-config/common-config.module.ts b/src/common/config/common-config/common-config.module.ts index e1f685a8..12853bc5 100644 --- a/src/common/config/common-config/common-config.module.ts +++ b/src/common/config/common-config/common-config.module.ts @@ -1,6 +1,7 @@ import { Global, Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; +import { loadSecretsFromFiles } from '@common/utils/load-secrets-from-files'; import { validateEnvConfig } from '@common/utils/validate-env-config'; import { configSchema, Env } from '../app-config'; @@ -15,7 +16,11 @@ import { NotificationsConfigService } from './notifications-config.service'; isGlobal: true, cache: true, envFilePath: '.env', - validate: (config) => validateEnvConfig(configSchema, config), + validate: (config) => + validateEnvConfig( + configSchema, + loadSecretsFromFiles(config, Object.keys(configSchema.shape)), + ), load: [notificationsConfig], }), ], diff --git a/src/common/utils/load-secrets-from-files.ts b/src/common/utils/load-secrets-from-files.ts new file mode 100644 index 00000000..c93b23c0 --- /dev/null +++ b/src/common/utils/load-secrets-from-files.ts @@ -0,0 +1,60 @@ +import { readFileSync } from 'node:fs'; + +/** + * Docker/Podman secrets support. + * + * For every known variable `X` the value can be provided in a file by setting `X_FILE` + * (e.g. `APP_SECRET_FILE=/run/secrets/app_secret`), the same convention the official + * postgres/mysql images use. Resolved values are written both to the returned config + * and to `process.env`, so consumers that read `process.env` directly (Prisma) see them too. + * + * Secret values are never included in error messages or logs. + */ +export function loadSecretsFromFiles>( + config: T, + keys: readonly string[], +): T { + const resolvedConfig: Record = { ...config }; + + for (const key of keys) { + const fileKey = `${key}_FILE`; + const filePath = nonEmptyString(resolvedConfig[fileKey]); + + if (!filePath) { + continue; + } + + if (nonEmptyString(resolvedConfig[key])) { + throw new Error( + `❌ ${key} and ${fileKey} are both set. Remove one of them and restart the application.`, + ); + } + + let value: string; + + try { + value = readFileSync(filePath, 'utf8'); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code ?? 'unknown error'; + + throw new Error( + `❌ ${fileKey} points to "${filePath}", which can not be read: ${code}`, + ); + } + + value = value.replace(/(\r?\n)+$/, ''); + + if (!value) { + throw new Error(`❌ ${fileKey} points to "${filePath}", which is empty.`); + } + + resolvedConfig[key] = value; + process.env[key] = value; + } + + return resolvedConfig as T; +} + +function nonEmptyString(value: unknown): string | undefined { + return typeof value === 'string' && value !== '' ? value : undefined; +} From d65e3ea50c7f2bc9e680199ced32053ef9aecc43 Mon Sep 17 00:00:00 2001 From: bebricoOOOOOOf <234202278+bebricoOOOOOOf@users.noreply.github.com> Date: Fri, 21 Aug 2026 17:38:03 +0900 Subject: [PATCH 2/2] fix: reject an empty DATABASE_URL_FILE/DIRECT_URL_FILE The Nest-side loader already refuses a file that holds only newlines. The inlined copy in prisma.config.ts did not, so an empty DIRECT_URL_FILE was silently replaced by DATABASE_URL and an empty DATABASE_URL_FILE surfaced as an unrelated datasource error. Both now fail with the same message as the loader. Co-Authored-By: Claude Opus 5 --- prisma.config.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/prisma.config.ts b/prisma.config.ts index 2bffd26f..80f93b6d 100644 --- a/prisma.config.ts +++ b/prisma.config.ts @@ -17,7 +17,13 @@ for (const key of ['DATABASE_URL', 'DIRECT_URL']) { throw new Error(`${key} and ${key}_FILE are both set. Remove one of them.`); } - process.env[key] = readFileSync(filePath, 'utf8').replace(/(\r?\n)+$/, ''); + const value = readFileSync(filePath, 'utf8').replace(/(\r?\n)+$/, ''); + + if (!value) { + throw new Error(`${key}_FILE points to "${filePath}", which is empty.`); + } + + process.env[key] = value; } if (!process.env.DIRECT_URL) {