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 <noreply@anthropic.com>
This commit is contained in:
Tom Moor
2026-03-30 19:42:20 -04:00
committed by GitHub
parent 7f6ec4ae31
commit e354db8164
3 changed files with 177 additions and 0 deletions
+16
View File
@@ -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=
+120
View File
@@ -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<string, string | undefined> = {
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<string, string | undefined> = {
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<string, string | undefined> = {
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<string, string | undefined> = {
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<string, string | undefined> = {
_FILE: secretFile,
};
resolveFileSecrets(env);
expect(env[""]).toBeUndefined();
});
it("should handle missing file gracefully", () => {
const env: Record<string, string | undefined> = {
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<string, string | undefined> = {
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<string, string | undefined> = {
SECRET_KEY_FILE: file1,
DATABASE_PASSWORD_FILE: file2,
};
resolveFileSecrets(env);
expect(env.SECRET_KEY).toBe("value1");
expect(env.DATABASE_PASSWORD).toBe("value2");
});
});
+41
View File
@@ -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<string, string | undefined>
): 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;