feat(docker): support file-backed secrets

Load mounted secrets before database migrations while preserving direct environment values for existing deployments.

Closes #373
This commit is contained in:
neon
2026-09-14 17:02:03 +00:00
parent c316e52e65
commit 2ced82794f
6 changed files with 250 additions and 2 deletions
+20
View File
@@ -28,6 +28,15 @@ JWT_SECRET=change-this-to-a-long-random-secret
# Required for the initial /auth/setup endpoint (x-setup-token header)
SETUP_BOOTSTRAP_TOKEN=change-this-to-a-long-random-secret
# Sensitive values can instead be read from mounted files by appending _FILE.
# Leave the direct value blank, then set its _FILE variable to the container path.
# Keep the blank assignment: docker-compose.yml requires these keys to be present.
# A non-empty direct value and its _FILE variable are mutually exclusive.
# The file must be mounted into every service that uses it, and must not be empty.
# POSTGRES_PASSWORD_FILE=/run/secrets/bookorbit_postgres_password
# JWT_SECRET_FILE=/run/secrets/bookorbit_jwt_secret
# SETUP_BOOTSTRAP_TOKEN_FILE=/run/secrets/bookorbit_setup_bootstrap_token
# External/public URL used by emails and Kobo endpoints
APP_URL=https://bookorbit.example.com
@@ -40,6 +49,7 @@ NODE_MAX_OLD_SPACE_SIZE=2048
# postgres service from docker-compose.yml. Your instance must have the
# uuid-ossp, pg_trgm, and vector (pgvector) extensions available.
# DATABASE_URL=postgres://user:password@yourhost:5432/bookorbit
# DATABASE_URL_FILE=/run/secrets/bookorbit_database_url
# Or set individual parts (POSTGRES_HOST, POSTGRES_PORT, POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DB)
# CORS origin for the frontend when served from a different domain than APP_URL
@@ -54,9 +64,19 @@ NODE_MAX_OLD_SPACE_SIZE=2048
# Recommended if you store SMTP provider credentials (generate: openssl rand -hex 32)
# EMAIL_ENCRYPTION_KEY=
# EMAIL_ENCRYPTION_KEY_FILE=/run/secrets/bookorbit_email_encryption_key
# Recommended if you store migration source credentials (generate: openssl rand -hex 32)
# MIGRATION_ENCRYPTION_KEY=
# MIGRATION_ENCRYPTION_KEY_FILE=/run/secrets/bookorbit_migration_encryption_key
# Required before download-client or indexer credentials can be saved.
# BOOK_REQUEST_ENCRYPTION_KEY=
# BOOK_REQUEST_ENCRYPTION_KEY_FILE=/run/secrets/bookorbit_book_request_encryption_key
# Optional GitHub token used to read release notes, raising the anonymous API rate limit.
# GITHUB_RELEASES_TOKEN=
# GITHUB_RELEASES_TOKEN_FILE=/run/secrets/bookorbit_github_releases_token
# Absolute container path that authorizes local migration backup files.
# Mount a dedicated host directory at this path as read-only before enabling backup mode.
+2 -2
View File
@@ -71,12 +71,12 @@ ENV PORT=3000
COPY --from=server-builder --chown=node:node /deploy ./
COPY --from=client-builder --chown=node:node /app/client/dist ./public
COPY --from=server-builder --chown=node:node /app/server/entrypoint.sh ./entrypoint.sh
COPY --from=server-builder --chown=node:node /app/server/entrypoint.sh /app/server/file-env.sh ./
COPY --chown=node:node LICENSE NOTICE ADDITIONAL_TERMS.md ./
COPY --chown=node:node server/bin/kepubify/ ./bin/kepubify/
COPY --chown=node:node koreader-plugin/bookorbit.koplugin/ ./koreader-plugin/bookorbit.koplugin/
RUN sed -i 's/\r$//' /app/entrypoint.sh && chmod +x /app/entrypoint.sh /app/bin/kepubify/* && mkdir -p /books /data/covers /data/book-bucket /tmp && chown -R node:node /data /tmp
RUN sed -i 's/\r$//' /app/entrypoint.sh /app/file-env.sh && chmod +x /app/entrypoint.sh /app/bin/kepubify/* && mkdir -p /books /data/covers /data/book-bucket /tmp && chown -R node:node /data /tmp
EXPOSE 3000
+20
View File
@@ -85,6 +85,26 @@ JWT_SECRET= # signs login tokens - openssl rand -hex 32
SETUP_BOOTSTRAP_TOKEN= # one-time setup wizard token - openssl rand -hex 16
```
Sensitive values can instead be supplied through mounted files by setting the corresponding `_FILE` variable, such as `JWT_SECRET_FILE=/run/secrets/bookorbit_jwt_secret`. Leave the direct variable blank, mount the file into every service that uses it, and restart. Keep the blank assignment rather than deleting the line: the bundled `docker-compose.yml` marks `POSTGRES_PASSWORD`, `JWT_SECRET`, and `SETUP_BOOTSTRAP_TOKEN` as required, so removing them entirely fails before the container starts. BookOrbit rejects configurations where both forms are non-empty, and rejects a file that is missing, unreadable, or empty. This convention is supported for database credentials, application encryption keys, `GITHUB_RELEASES_TOKEN`, `JWT_SECRET`, and `SETUP_BOOTSTRAP_TOKEN`.
For example, a Compose override can mount one database password file into both containers:
```yaml
services:
app:
secrets: [bookorbit_postgres_password]
postgres:
environment:
POSTGRES_PASSWORD_FILE: ${POSTGRES_PASSWORD_FILE?required}
secrets: [bookorbit_postgres_password]
secrets:
bookorbit_postgres_password:
file: ./secrets/postgres_password
```
Set `POSTGRES_PASSWORD=` and `POSTGRES_PASSWORD_FILE=/run/secrets/bookorbit_postgres_password` in `.env`. The override passes the file path to PostgreSQL while the app receives it from the existing `.env` import. Other container platforms may mount secret files directly without a Compose override.
On a NAS, or any host where your book folder is owned by a user other than UID 1000, also set `PUID` and `PGID` to match that owner. Run `id -u` and `id -g` as the owning user to find them. Getting these wrong is the most common cause of permission errors on first scan.
Optionally set `LIBRARY_BROWSE_ROOT=/books` to start the library folder picker at `/books` instead of `/`.
+5
View File
@@ -1,6 +1,9 @@
#!/bin/sh
set -e
script_dir="$(dirname -- "$0")"
. "$script_dir/file-env.sh"
log() {
echo "BookOrbit startup: $*" >&2
}
@@ -109,6 +112,8 @@ urlencode() {
node -e 'process.stdout.write(encodeURIComponent(process.argv[1]))' "$1"
}
load_file_env
if [ -z "$DATABASE_URL" ]; then
encoded_user="$(urlencode "$POSTGRES_USER")"
encoded_password="$(urlencode "$POSTGRES_PASSWORD")"
+79
View File
@@ -0,0 +1,79 @@
#!/bin/sh
FILE_ENV_MAX_BYTES=65536
# POSIX sh has no `local`, so clear the working values instead of leaving secrets
# readable in the calling shell.
file_env_reset() {
unset file_env_name file_var_name direct_value file_path file_value file_size
}
file_env() {
file_env_name="$1"
file_var_name="${file_env_name}_FILE"
direct_value="$(printenv "$file_env_name" 2>/dev/null || true)"
file_path="$(printenv "$file_var_name" 2>/dev/null || true)"
if [ -n "$direct_value" ] && [ -n "$file_path" ]; then
echo "BookOrbit startup: $file_env_name and $file_var_name are mutually exclusive." >&2
file_env_reset
return 1
fi
if [ -n "$direct_value" ] || [ -z "$file_path" ]; then
file_env_reset
return 0
fi
if [ ! -f "$file_path" ] || [ ! -r "$file_path" ]; then
echo "BookOrbit startup: $file_var_name must point to a readable regular file." >&2
file_env_reset
return 1
fi
file_size="$(wc -c < "$file_path" | tr -d '[:space:]')"
case "$file_size" in
'' | *[!0-9]*)
echo "BookOrbit startup: could not determine the size of $file_var_name." >&2
file_env_reset
return 1
;;
esac
if [ "$file_size" -gt "$FILE_ENV_MAX_BYTES" ]; then
echo "BookOrbit startup: $file_var_name exceeds the ${FILE_ENV_MAX_BYTES}-byte limit." >&2
file_env_reset
return 1
fi
file_value="$(cat < "$file_path")"
if [ -z "$file_value" ]; then
echo "BookOrbit startup: $file_var_name points to an empty file." >&2
file_env_reset
return 1
fi
export "$file_env_name=$file_value"
unset "$file_var_name"
file_env_reset
}
load_file_env() {
for pending_env_name in \
BOOK_REQUEST_ENCRYPTION_KEY \
DATABASE_URL \
EMAIL_ENCRYPTION_KEY \
GITHUB_RELEASES_TOKEN \
JWT_SECRET \
MIGRATION_ENCRYPTION_KEY \
POSTGRES_PASSWORD \
SETUP_BOOTSTRAP_TOKEN
do
if ! file_env "$pending_env_name"; then
unset pending_env_name
return 1
fi
done
unset pending_env_name
}
+124
View File
@@ -0,0 +1,124 @@
import { spawnSync } from 'node:child_process';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
const helperPath = resolve(__dirname, '../../file-env.sh');
describe('Docker file-backed environment values', () => {
let secretDir: string;
beforeEach(() => {
secretDir = mkdtempSync(join(tmpdir(), 'bookorbit-file-env-'));
});
afterEach(() => {
rmSync(secretDir, { recursive: true, force: true });
});
it('preserves an existing direct value', () => {
const result = runHelper('file_env JWT_SECRET; printf "%s" "$JWT_SECRET"', {
JWT_SECRET: 'direct-secret-value',
});
expect(result.status).toBe(0);
expect(result.stdout).toBe('direct-secret-value');
});
it('loads a file value and removes the file variable', () => {
const secretPath = join(secretDir, 'jwt-secret');
writeFileSync(secretPath, ' file-secret-value \n\n');
const result = runHelper('file_env JWT_SECRET; printf "%s|%s" "$JWT_SECRET" "${JWT_SECRET_FILE-unset}"', {
JWT_SECRET: '',
JWT_SECRET_FILE: secretPath,
});
expect(result.status).toBe(0);
expect(result.stdout).toBe(' file-secret-value |unset');
});
it('loads application encryption keys', () => {
const secretPath = join(secretDir, 'book-request-key');
writeFileSync(secretPath, 'book-request-encryption-key');
const result = runHelper('load_file_env; printf "%s" "$BOOK_REQUEST_ENCRYPTION_KEY"', {
BOOK_REQUEST_ENCRYPTION_KEY_FILE: secretPath,
});
expect(result.status).toBe(0);
expect(result.stdout).toBe('book-request-encryption-key');
});
it('rejects non-empty direct and file values together', () => {
const secretPath = join(secretDir, 'jwt-secret');
writeFileSync(secretPath, 'file-secret-value');
const result = runHelper('file_env JWT_SECRET', {
JWT_SECRET: 'direct-secret-value',
JWT_SECRET_FILE: secretPath,
});
expect(result.status).toBe(1);
expect(result.stderr).toContain('JWT_SECRET and JWT_SECRET_FILE are mutually exclusive');
});
it('rejects missing, empty, and oversized files without exposing their paths', () => {
const missingPath = join(secretDir, 'missing-secret');
const missingResult = runHelper('file_env JWT_SECRET', { JWT_SECRET_FILE: missingPath });
expect(missingResult.status).toBe(1);
expect(missingResult.stderr).toContain('JWT_SECRET_FILE must point to a readable regular file');
expect(missingResult.stderr).not.toContain(missingPath);
const emptyPath = join(secretDir, 'empty-secret');
writeFileSync(emptyPath, '\n');
const emptyResult = runHelper('file_env JWT_SECRET', { JWT_SECRET_FILE: emptyPath });
expect(emptyResult.status).toBe(1);
expect(emptyResult.stderr).toContain('JWT_SECRET_FILE points to an empty file');
expect(emptyResult.stderr).not.toContain(emptyPath);
const oversizedPath = join(secretDir, 'oversized-secret');
writeFileSync(oversizedPath, 'x'.repeat(65_537));
const oversizedResult = runHelper('file_env JWT_SECRET', { JWT_SECRET_FILE: oversizedPath });
expect(oversizedResult.status).toBe(1);
expect(oversizedResult.stderr).toContain('JWT_SECRET_FILE exceeds the 65536-byte limit');
});
it('does not leave the loaded secret in a shell variable', () => {
const secretPath = join(secretDir, 'jwt-secret');
writeFileSync(secretPath, 'file-secret-value');
const result = runHelper('load_file_env; printf "%s|%s" "${file_value-unset}" "${direct_value-unset}"', {
JWT_SECRET_FILE: secretPath,
});
expect(result.status).toBe(0);
expect(result.stdout).toBe('unset|unset');
});
it('aborts the calling script under set -e', () => {
const secretPath = join(secretDir, 'jwt-secret');
writeFileSync(secretPath, 'file-secret-value');
const result = runHelper('set -e\nload_file_env\nprintf "unreachable"', {
JWT_SECRET: 'direct-secret-value',
JWT_SECRET_FILE: secretPath,
});
expect(result.status).toBe(1);
expect(result.stdout).toBe('');
});
});
function runHelper(script: string, env: NodeJS.ProcessEnv = {}) {
return spawnSync('sh', ['-c', '. "$1"\n' + script, 'sh', helperPath], {
env: {
PATH: process.env.PATH ?? '/usr/bin:/bin',
...env,
},
encoding: 'utf8',
});
}