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 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
# 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.
# Point this at your library root; use an absolute path on NAS/Portainer installs.
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
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", "--"]
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.
| Variable | Purpose | Default (dev) |
| --------------------- | ---------------------------------- | --------------------------------------------------------- |
| `DATABASE_URL` | PostgreSQL connection string | `postgres://bookorbit:bookorbit@localhost:5432/bookorbit` |
| `PORT` | Server listen port | `3000` |
| `NODE_ENV` | Runtime mode | `development` |
| `JWT_SECRET` | Signing key for auth tokens | `change-me-in-production` |
| `APP_DATA_PATH` | Storage for covers, avatars, cache | `../local/data` |
| `LIBRARY_BROWSE_ROOT` | Library folder picker root | `/` |
| `APP_URL` | Base URL for email links | `http://localhost:5173` |
| `SWAGGER_ENABLED` | Serve Swagger UI and OpenAPI JSON | `false` |
| `DISABLE_LOCAL_AUTH` | Disable password authentication | `false` |
| Variable | Purpose | Default (dev) |
| --------------------- | ------------------------------------ | --------------------------------------------------------- |
| `DATABASE_URL` | PostgreSQL connection string | `postgres://bookorbit:bookorbit@localhost:5432/bookorbit` |
| `PORT` | Server listen port | `3000` |
| `HOST` | Server listen address (IPv4 or IPv6) | `0.0.0.0` |
| `NODE_ENV` | Runtime mode | `development` |
| `JWT_SECRET` | Signing key for auth tokens | `change-me-in-production` |
| `APP_DATA_PATH` | Storage for covers, avatars, cache | `../local/data` |
| `LIBRARY_BROWSE_ROOT` | Library folder picker root | `/` |
| `APP_URL` | Base URL for email links | `http://localhost:5173` |
| `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
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
# API docs (optional)
+14
View File
@@ -7,6 +7,7 @@ const ORIGINAL_ENV = process.env;
function resetEnv(): void {
process.env = { ...ORIGINAL_ENV };
delete process.env.NODE_ENV;
delete process.env.HOST;
delete process.env.APP_URL;
delete process.env.APP_VERSION;
delete process.env.OIDC_ALLOW_LOCAL_ISSUERS;
@@ -46,6 +47,7 @@ describe('config', () => {
it('uses app defaults, including local-build fallback version', () => {
expect(appConfig()).toEqual({
nodeEnv: 'development',
host: '0.0.0.0',
appUrl: 'http://localhost:5173',
version: 'Local build',
githubReleasesRepo: 'bookorbit/bookorbit',
@@ -59,6 +61,7 @@ describe('config', () => {
it('reads app values from environment when provided', () => {
process.env.NODE_ENV = 'production';
process.env.HOST = '127.0.0.1';
process.env.APP_URL = 'https://bookorbit.local';
process.env.APP_VERSION = 'v2.3.4';
process.env.OIDC_ALLOW_LOCAL_ISSUERS = 'true';
@@ -70,6 +73,7 @@ describe('config', () => {
expect(appConfig()).toEqual({
nodeEnv: 'production',
host: '127.0.0.1',
appUrl: 'https://bookorbit.local',
version: 'v2.3.4',
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', () => {
process.env.OIDC_ALLOW_LOCAL_ISSUERS = 'maybe';
expect(appConfig().oidcAllowLocalIssuers).toBe(false);
+1
View File
@@ -3,6 +3,7 @@ import { join, resolve } from 'path';
export const appConfig = registerAs('app', () => ({
nodeEnv: process.env.NODE_ENV ?? 'development',
host: process.env.HOST?.trim() || '0.0.0.0',
appUrl: process.env.APP_URL ?? 'http://localhost:5173',
version: process.env.APP_VERSION ?? 'Local build',
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', () => {
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', () => {
const urls = [
'postgres://bookorbit:bookorbit@localhost:5432/bookorbit',
+6
View File
@@ -1,5 +1,6 @@
import { parseIntoClientConfig } from 'pg-connection-string';
import { isAbsolute } from 'node:path';
import { isIP } from 'node:net';
import { z } from 'zod';
const BOOLEAN_ENV_VALUES = ['true', 'false', '1', '0', 'yes', 'no', 'on', 'off'];
@@ -45,6 +46,11 @@ function trustProxyEnv() {
const envSchema = z.object({
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(),
JWT_SECRET: z
.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();
await app.listen(process.env.PORT ?? 3000, '0.0.0.0');
await app.listen(process.env.PORT ?? 3000, appConfiguration.host);
}
bootstrap().catch((err: unknown) => {