feat(server): allow configuring the listen address

Allow native and host-network deployments to select an IPv4 or IPv6 bind
address through HOST while preserving the existing wildcard default.
Keep Docker health checks aligned with the configured listener and document
how to restrict host port publishing independently of the container address.
Cover validation, real bootstrap sockets, and health-check commands with tests.

Closes #1315
This commit is contained in:
neon
2026-09-09 10:26:12 +00:00
parent 4febe9ee45
commit 04a0f21c8b
11 changed files with 173 additions and 13 deletions
+5
View File
@@ -2,8 +2,13 @@
APP_IMAGE=ghcr.io/bookorbit/bookorbit:latest APP_IMAGE=ghcr.io/bookorbit/bookorbit:latest
# App publish port on host # App publish port on host
# For access only from this host (such as a host reverse proxy), use 127.0.0.1:3000.
APP_PORT=3000 APP_PORT=3000
# Optional listen address inside the container (IPv4 or IPv6, without brackets).
# Keep the default for bridge networking; use APP_PORT above to restrict host exposure.
# HOST=0.0.0.0
# Host folder that appears as /books in the container. # Host folder that appears as /books in the container.
# Point this at your library root; use an absolute path on NAS/Portainer installs. # Point this at your library root; use an absolute path on NAS/Portainer installs.
BOOKS_HOST_PATH=./books BOOKS_HOST_PATH=./books
+7 -1
View File
@@ -80,7 +80,13 @@ RUN sed -i 's/\r$//' /app/entrypoint.sh && chmod +x /app/entrypoint.sh /app/bin/
EXPOSE 3000 EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD wget -q -T 4 -O /dev/null "http://127.0.0.1:${PORT:-3000}/api/v1/health" CMD host="$(printf '%s' "${HOST:-}" | tr -d '[:space:]')"; \
case "$host" in \
''|0.0.0.0) host=127.0.0.1 ;; \
::) host='[::1]' ;; \
*:*) host="[$host]" ;; \
esac; \
wget -q -T 4 -O /dev/null "http://${host}:${PORT:-3000}/api/v1/health"
ENTRYPOINT ["/sbin/tini", "-s", "--"] ENTRYPOINT ["/sbin/tini", "-s", "--"]
CMD ["sh", "/app/entrypoint.sh"] CMD ["sh", "/app/entrypoint.sh"]
+16 -11
View File
@@ -224,17 +224,22 @@ pnpm --filter @bookorbit/types dev
These are the variables you are most likely to need during development. See `server/.env.example` for the full list with comments. These are the variables you are most likely to need during development. See `server/.env.example` for the full list with comments.
| Variable | Purpose | Default (dev) | | Variable | Purpose | Default (dev) |
| --------------------- | ---------------------------------- | --------------------------------------------------------- | | --------------------- | ------------------------------------ | --------------------------------------------------------- |
| `DATABASE_URL` | PostgreSQL connection string | `postgres://bookorbit:bookorbit@localhost:5432/bookorbit` | | `DATABASE_URL` | PostgreSQL connection string | `postgres://bookorbit:bookorbit@localhost:5432/bookorbit` |
| `PORT` | Server listen port | `3000` | | `PORT` | Server listen port | `3000` |
| `NODE_ENV` | Runtime mode | `development` | | `HOST` | Server listen address (IPv4 or IPv6) | `0.0.0.0` |
| `JWT_SECRET` | Signing key for auth tokens | `change-me-in-production` | | `NODE_ENV` | Runtime mode | `development` |
| `APP_DATA_PATH` | Storage for covers, avatars, cache | `../local/data` | | `JWT_SECRET` | Signing key for auth tokens | `change-me-in-production` |
| `LIBRARY_BROWSE_ROOT` | Library folder picker root | `/` | | `APP_DATA_PATH` | Storage for covers, avatars, cache | `../local/data` |
| `APP_URL` | Base URL for email links | `http://localhost:5173` | | `LIBRARY_BROWSE_ROOT` | Library folder picker root | `/` |
| `SWAGGER_ENABLED` | Serve Swagger UI and OpenAPI JSON | `false` | | `APP_URL` | Base URL for email links | `http://localhost:5173` |
| `DISABLE_LOCAL_AUTH` | Disable password authentication | `false` | | `SWAGGER_ENABLED` | Serve Swagger UI and OpenAPI JSON | `false` |
| `DISABLE_LOCAL_AUTH` | Disable password authentication | `false` |
For a native server accessible only on this machine, set `HOST=127.0.0.1` in `server/.env`. `HOST` accepts an IPv4 or IPv6 address without a port or brackets, such as `::1` for IPv6 loopback. An unset or blank value keeps the existing `0.0.0.0` default. `PORT` continues to select the listen port.
With Docker bridge networking, keep `HOST` unset and set `APP_PORT=127.0.0.1:3000` in the deployment `.env` to publish only on host loopback. `HOST` controls the container's listener, while `APP_PORT` controls host port publishing. Binding to loopback inside the container prevents normal port forwarding and access from other containers. A reverse proxy in another container can instead use the shared Docker network with the app's published port removed. With host networking, `HOST` controls the host's listener directly.
--- ---
+3
View File
@@ -3,6 +3,9 @@ DATABASE_URL=postgres://bookorbit:bookorbit@localhost:5432/bookorbit
# App # App
PORT=3000 PORT=3000
# Listen address (IPv4 or IPv6, without brackets). Unset or blank keeps 0.0.0.0.
# Use 127.0.0.1 for local-only native deployments.
# HOST=0.0.0.0
NODE_ENV=development NODE_ENV=development
# API docs (optional) # API docs (optional)
+14
View File
@@ -7,6 +7,7 @@ const ORIGINAL_ENV = process.env;
function resetEnv(): void { function resetEnv(): void {
process.env = { ...ORIGINAL_ENV }; process.env = { ...ORIGINAL_ENV };
delete process.env.NODE_ENV; delete process.env.NODE_ENV;
delete process.env.HOST;
delete process.env.APP_URL; delete process.env.APP_URL;
delete process.env.APP_VERSION; delete process.env.APP_VERSION;
delete process.env.OIDC_ALLOW_LOCAL_ISSUERS; delete process.env.OIDC_ALLOW_LOCAL_ISSUERS;
@@ -46,6 +47,7 @@ describe('config', () => {
it('uses app defaults, including local-build fallback version', () => { it('uses app defaults, including local-build fallback version', () => {
expect(appConfig()).toEqual({ expect(appConfig()).toEqual({
nodeEnv: 'development', nodeEnv: 'development',
host: '0.0.0.0',
appUrl: 'http://localhost:5173', appUrl: 'http://localhost:5173',
version: 'Local build', version: 'Local build',
githubReleasesRepo: 'bookorbit/bookorbit', githubReleasesRepo: 'bookorbit/bookorbit',
@@ -59,6 +61,7 @@ describe('config', () => {
it('reads app values from environment when provided', () => { it('reads app values from environment when provided', () => {
process.env.NODE_ENV = 'production'; process.env.NODE_ENV = 'production';
process.env.HOST = '127.0.0.1';
process.env.APP_URL = 'https://bookorbit.local'; process.env.APP_URL = 'https://bookorbit.local';
process.env.APP_VERSION = 'v2.3.4'; process.env.APP_VERSION = 'v2.3.4';
process.env.OIDC_ALLOW_LOCAL_ISSUERS = 'true'; process.env.OIDC_ALLOW_LOCAL_ISSUERS = 'true';
@@ -70,6 +73,7 @@ describe('config', () => {
expect(appConfig()).toEqual({ expect(appConfig()).toEqual({
nodeEnv: 'production', nodeEnv: 'production',
host: '127.0.0.1',
appUrl: 'https://bookorbit.local', appUrl: 'https://bookorbit.local',
version: 'v2.3.4', version: 'v2.3.4',
githubReleasesRepo: 'acme/app', githubReleasesRepo: 'acme/app',
@@ -81,6 +85,16 @@ describe('config', () => {
}); });
}); });
it.each(['', ' '])('preserves wildcard binding for blank HOST %j', (host) => {
process.env.HOST = host;
expect(appConfig().host).toBe('0.0.0.0');
});
it.each(['127.0.0.1', '192.0.2.10', '::1', '::'])('reads and trims bind address %s', (host) => {
process.env.HOST = ` ${host} `;
expect(appConfig().host).toBe(host);
});
it('falls back to false when OIDC_ALLOW_LOCAL_ISSUERS is invalid', () => { it('falls back to false when OIDC_ALLOW_LOCAL_ISSUERS is invalid', () => {
process.env.OIDC_ALLOW_LOCAL_ISSUERS = 'maybe'; process.env.OIDC_ALLOW_LOCAL_ISSUERS = 'maybe';
expect(appConfig().oidcAllowLocalIssuers).toBe(false); expect(appConfig().oidcAllowLocalIssuers).toBe(false);
+1
View File
@@ -3,6 +3,7 @@ import { join, resolve } from 'path';
export const appConfig = registerAs('app', () => ({ export const appConfig = registerAs('app', () => ({
nodeEnv: process.env.NODE_ENV ?? 'development', nodeEnv: process.env.NODE_ENV ?? 'development',
host: process.env.HOST?.trim() || '0.0.0.0',
appUrl: process.env.APP_URL ?? 'http://localhost:5173', appUrl: process.env.APP_URL ?? 'http://localhost:5173',
version: process.env.APP_VERSION ?? 'Local build', version: process.env.APP_VERSION ?? 'Local build',
githubReleasesRepo: process.env.GITHUB_RELEASES_REPO?.trim() || 'bookorbit/bookorbit', githubReleasesRepo: process.env.GITHUB_RELEASES_REPO?.trim() || 'bookorbit/bookorbit',
@@ -0,0 +1,38 @@
import { execFileSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
const dockerfile = readFileSync(resolve(__dirname, '../../../Dockerfile'), 'utf8');
const healthcheck = dockerfile.match(/HEALTHCHECK[^\n]*\\\n\s*CMD ([\s\S]*?)(?:\n\n|$)/)?.[1].replace(/\\\n/g, '\n');
describe('Docker healthcheck bind address', () => {
it.each([
{ host: undefined, port: undefined, expected: '127.0.0.1:3000' },
{ host: '', port: '', expected: '127.0.0.1:3000' },
{ host: ' ', port: '31015', expected: '127.0.0.1:31015' },
{ host: '0.0.0.0', port: '31015', expected: '127.0.0.1:31015' },
{ host: '127.0.0.1', port: '31015', expected: '127.0.0.1:31015' },
{ host: ' 192.0.2.10 ', port: '31015', expected: '192.0.2.10:31015' },
{ host: '::', port: '31015', expected: '[::1]:31015' },
{ host: '::1', port: '31015', expected: '[::1]:31015' },
{ host: '2001:db8::1', port: '31015', expected: '[2001:db8::1]:31015' },
])('probes $expected with HOST=$host and PORT=$port', ({ host, port, expected }) => {
expect(healthcheck).toBeDefined();
const env = { ...process.env };
delete env.HOST;
delete env.PORT;
if (host !== undefined) env.HOST = host;
if (port !== undefined) env.PORT = port;
const args = execFileSync('sh', ['-c', `wget() { printf '%s\\n' "$@"; }\n${healthcheck}`], { env, encoding: 'utf8' })
.trim()
.split('\n');
expect(args).toEqual(['-q', '-T', '4', '-O', '/dev/null', `http://${expected}/api/v1/health`]);
});
it('propagates a failed HTTP probe to Docker', () => {
expect(healthcheck).toBeDefined();
expect(() => execFileSync('sh', ['-c', `wget() { return 1; }\n${healthcheck}`])).toThrow();
});
});
+15
View File
@@ -6,6 +6,21 @@ const BASE_ENV = {
}; };
describe('validateEnv', () => { describe('validateEnv', () => {
it('allows HOST to remain unset', () => {
expect(validateEnv(BASE_ENV).HOST).toBeUndefined();
});
it.each(['', ' ', '0.0.0.0', '127.0.0.1', '192.0.2.10', '::', '::1', '2001:db8::1', ' 127.0.0.1 '])('accepts bind address %j', (HOST) => {
expect(validateEnv({ ...BASE_ENV, HOST }).HOST).toBe(HOST.trim());
});
it.each(['localhost', 'https://127.0.0.1', '127.0.0.1:3000', '[::1]', '256.0.0.1', '127.0.0.1/8', '127. 0.0.1'])(
'rejects invalid bind address %j',
(HOST) => {
expect(() => validateEnv({ ...BASE_ENV, HOST })).toThrow('HOST must be an IPv4 or IPv6 address without a port or brackets');
},
);
it('accepts common postgres URL formats used by existing setups', () => { it('accepts common postgres URL formats used by existing setups', () => {
const urls = [ const urls = [
'postgres://bookorbit:bookorbit@localhost:5432/bookorbit', 'postgres://bookorbit:bookorbit@localhost:5432/bookorbit',
+6
View File
@@ -1,5 +1,6 @@
import { parseIntoClientConfig } from 'pg-connection-string'; import { parseIntoClientConfig } from 'pg-connection-string';
import { isAbsolute } from 'node:path'; import { isAbsolute } from 'node:path';
import { isIP } from 'node:net';
import { z } from 'zod'; import { z } from 'zod';
const BOOLEAN_ENV_VALUES = ['true', 'false', '1', '0', 'yes', 'no', 'on', 'off']; const BOOLEAN_ENV_VALUES = ['true', 'false', '1', '0', 'yes', 'no', 'on', 'off'];
@@ -45,6 +46,11 @@ function trustProxyEnv() {
const envSchema = z.object({ const envSchema = z.object({
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
HOST: z
.string()
.trim()
.refine((value) => value === '' || isIP(value) !== 0, 'HOST must be an IPv4 or IPv6 address without a port or brackets')
.optional(),
DATABASE_URL: z.string().refine(isValidPostgresConnectionString, 'DATABASE_URL must be a valid PostgreSQL connection string').optional(), DATABASE_URL: z.string().refine(isValidPostgresConnectionString, 'DATABASE_URL must be a valid PostgreSQL connection string').optional(),
JWT_SECRET: z JWT_SECRET: z
.string() .string()
+67
View File
@@ -0,0 +1,67 @@
import { Test, type TestingModule } from '@nestjs/testing';
import type { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify';
import type { AddressInfo } from 'node:net';
vi.mock('./app.module', () => ({ AppModule: class {} }));
describe('server bind address', () => {
let app: NestFastifyApplication | undefined;
let moduleFixture: TestingModule;
beforeEach(() => {
vi.resetModules();
vi.stubEnv('NODE_ENV', 'test');
vi.stubEnv('PORT', '0');
vi.stubEnv('JWT_SECRET', 'bind-address-test-secret');
vi.stubEnv('SWAGGER_ENABLED', 'false');
});
afterEach(async () => {
await app?.close();
if (!app) await moduleFixture?.close();
app = undefined;
vi.restoreAllMocks();
vi.unstubAllEnvs();
});
it.each([
{ host: undefined, expected: '0.0.0.0' },
{ host: '', expected: '0.0.0.0' },
{ host: '127.0.0.1', expected: '127.0.0.1' },
{ host: ' 127.0.0.1 ', expected: '127.0.0.1' },
{ host: '::1', expected: '::1' },
])('starts the real bootstrap with HOST=$host on $expected', async ({ host, expected }) => {
vi.stubEnv('HOST', host);
const { NestFactory } = await import('@nestjs/core');
const { ConfigModule } = await import('@nestjs/config');
const { Logger } = await import('nestjs-pino');
const { appConfig } = await import('./config/config');
const { validateEnv } = await import('./config/env.validation');
moduleFixture = await Test.createTestingModule({
imports: [ConfigModule.forRoot({ ignoreEnvFile: true, validate: validateEnv, load: [appConfig] })],
providers: [{ provide: Logger, useValue: { log: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn(), verbose: vi.fn() } }],
}).compile();
const create = vi.spyOn(NestFactory, 'create').mockImplementation((_module, adapter) => {
const fastifyAdapter = adapter as FastifyAdapter;
fastifyAdapter.getInstance().get('/bind-probe', () => ({ ok: true }));
app = moduleFixture.createNestApplication<NestFastifyApplication>(fastifyAdapter, { logger: false });
vi.spyOn(app, 'listen');
return Promise.resolve(app);
});
await import('./main');
await vi.waitFor(() => {
expect(create).toHaveBeenCalledOnce();
expect(app?.getHttpServer().listening).toBe(true);
});
const address = app!.getHttpServer().address() as AddressInfo;
expect(address.address).toBe(expected);
expect(app!.listen).toHaveBeenCalledWith('0', expected);
const requestHost = expected === '::1' ? '[::1]' : '127.0.0.1';
const response = await fetch(`http://${requestHost}:${address.port}/bind-probe`, { signal: AbortSignal.timeout(3000) });
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ ok: true });
});
});
+1 -1
View File
@@ -121,7 +121,7 @@ async function bootstrap() {
} }
app.enableShutdownHooks(); app.enableShutdownHooks();
await app.listen(process.env.PORT ?? 3000, '0.0.0.0'); await app.listen(process.env.PORT ?? 3000, appConfiguration.host);
} }
bootstrap().catch((err: unknown) => { bootstrap().catch((err: unknown) => {