From e354db8164499fa06647d7725bdc069e37a812f1 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Mon, 30 Mar 2026 19:42:20 -0400 Subject: [PATCH] feat: Add support for Docker Swarm style secrets (#11906) * feat: Add support for Docker Swarm style secrets * fix: Handle empty-string env values and bare _FILE key in resolveFileSecrets Use undefined check instead of truthiness so empty-string values are treated as "already set" and not overridden by _FILE variants. Skip processing when the key is exactly "_FILE" to avoid creating an empty-key entry. Co-Authored-By: Claude Opus 4.6 --- .env.sample | 16 +++++ server/utils/environment.test.ts | 120 +++++++++++++++++++++++++++++++ server/utils/environment.ts | 41 +++++++++++ 3 files changed, 177 insertions(+) create mode 100644 server/utils/environment.test.ts diff --git a/.env.sample b/.env.sample index a129ca4d4d..f6d37d6990 100644 --- a/.env.sample +++ b/.env.sample @@ -1,5 +1,21 @@ NODE_ENV=production +# ––––––––––––––––––––––––––––––––––––––––– +# ––––––––––– FILE-BASED SECRETS –––––––– +# ––––––––––––––––––––––––––––––––––––––––– +# +# Any environment variable can be loaded from a file by appending _FILE to the +# variable name and setting the value to the path of the file. This is useful +# for Docker secrets and other file-based secret management systems. +# +# For example, instead of: +# SECRET_KEY=your_secret_key +# You can use: +# SECRET_KEY_FILE=/run/secrets/outline_secret_key +# +# The file contents will be trimmed of leading/trailing whitespace. If both the +# variable and the _FILE variant are set, the direct variable takes precedence. + # This URL should point to the fully qualified, publicly accessible, URL. If using a # proxy this will be the proxy's URL. URL= diff --git a/server/utils/environment.test.ts b/server/utils/environment.test.ts new file mode 100644 index 0000000000..90c859c18c --- /dev/null +++ b/server/utils/environment.test.ts @@ -0,0 +1,120 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { resolveFileSecrets } from "./environment"; + +describe("resolveFileSecrets", () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "outline-env-test-")); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true }); + }); + + it("should read env value from file when _FILE suffix is used", () => { + const secretFile = path.join(tmpDir, "secret"); + fs.writeFileSync(secretFile, "my-secret-value"); + + const env: Record = { + TEST_SECRET_FILE: secretFile, + }; + + resolveFileSecrets(env); + + expect(env.TEST_SECRET).toBe("my-secret-value"); + }); + + it("should trim whitespace and newlines from file contents", () => { + const secretFile = path.join(tmpDir, "secret"); + fs.writeFileSync(secretFile, " my-secret-value\n\n"); + + const env: Record = { + TEST_TRIM_FILE: secretFile, + }; + + resolveFileSecrets(env); + + expect(env.TEST_TRIM).toBe("my-secret-value"); + }); + + it("should not override existing env value with _FILE", () => { + const secretFile = path.join(tmpDir, "secret"); + fs.writeFileSync(secretFile, "file-value"); + + const env: Record = { + TEST_OVERRIDE: "direct-value", + TEST_OVERRIDE_FILE: secretFile, + }; + + resolveFileSecrets(env); + + expect(env.TEST_OVERRIDE).toBe("direct-value"); + }); + + it("should not override empty-string env value with _FILE", () => { + const secretFile = path.join(tmpDir, "secret"); + fs.writeFileSync(secretFile, "file-value"); + + const env: Record = { + TEST_OVERRIDE_EMPTY: "", + TEST_OVERRIDE_EMPTY_FILE: secretFile, + }; + + resolveFileSecrets(env); + + expect(env.TEST_OVERRIDE_EMPTY).toBe(""); + }); + + it("should skip a bare _FILE key with no base name", () => { + const secretFile = path.join(tmpDir, "secret"); + fs.writeFileSync(secretFile, "value"); + + const env: Record = { + _FILE: secretFile, + }; + + resolveFileSecrets(env); + + expect(env[""]).toBeUndefined(); + }); + + it("should handle missing file gracefully", () => { + const env: Record = { + TEST_MISSING_FILE: path.join(tmpDir, "nonexistent"), + }; + + resolveFileSecrets(env); + + expect(env.TEST_MISSING).toBeUndefined(); + }); + + it("should skip _FILE entries with empty path", () => { + const env: Record = { + TEST_EMPTY_FILE: "", + }; + + resolveFileSecrets(env); + + expect(env.TEST_EMPTY).toBeUndefined(); + }); + + it("should process multiple _FILE entries", () => { + const file1 = path.join(tmpDir, "secret1"); + const file2 = path.join(tmpDir, "secret2"); + fs.writeFileSync(file1, "value1"); + fs.writeFileSync(file2, "value2"); + + const env: Record = { + SECRET_KEY_FILE: file1, + DATABASE_PASSWORD_FILE: file2, + }; + + resolveFileSecrets(env); + + expect(env.SECRET_KEY).toBe("value1"); + expect(env.DATABASE_PASSWORD).toBe("value2"); + }); +}); diff --git a/server/utils/environment.ts b/server/utils/environment.ts index cc020cee7a..0117494a9e 100644 --- a/server/utils/environment.ts +++ b/server/utils/environment.ts @@ -36,4 +36,45 @@ process.env = { ...process.env, }; +/** + * Process environment variables with _FILE suffix by reading the referenced + * file and setting the base variable. If the base variable is already set, the + * file is not read. File contents are trimmed of leading/trailing whitespace. + * + * @param env - the environment record to process. + */ +export function resolveFileSecrets( + env: Record +): void { + for (const key of Object.keys(env)) { + if (key.endsWith("_FILE")) { + const baseKey = key.slice(0, -5); + if (!baseKey.length) { + continue; + } + + const filePath = env[key]; + + if (!filePath) { + continue; + } + + if (env[baseKey] !== undefined) { + continue; + } + + try { + env[baseKey] = fs.readFileSync(filePath, "utf8").trim(); + } catch (err) { + // oxlint-disable-next-line no-console + console.error( + `Failed to read file for ${key} (${filePath}): ${(err as Error).message}` + ); + } + } + } +} + +resolveFileSecrets(process.env); + export default process.env;