Skip to content

Environment Variables ​

API Service (Hono) ​

VariableRequiredDefaultPurpose
NODE_ENVYes—development, production, or test. Must be explicit so omitted configuration cannot disable production safeguards.
POSTGRES_HOSTYes—Postgres host. Combined with the vars below into the connection URL by src/lib/resolve-database-url.ts.
POSTGRES_PORTNo5432Postgres port.
POSTGRES_USERYes—Postgres user.
POSTGRES_PASSWORDYes—Postgres password.
POSTGRES_DBYes—Postgres database name.
REDIS_HOSTYes—Redis host. Combined with the vars below into the connection URL by src/lib/resolve-redis-url.ts.
REDIS_PORTNo6379Redis port.
REDIS_USERNo—Redis ACL user (leave unset unless your deployment uses ACLs).
REDIS_PASSWORDNo—Redis password.
REDIS_TLSNo—Set to 1 or true to use rediss:// (TLS). Required for most managed Redis providers.
LIBRIS_INBOX_PATHYes—Directory watched for new book files
LIBRIS_LIBRARY_PATHYes—Directory for organized book storage
LIBRIS_COVER_FETCH_ALLOWLISTNo—Comma-separated exact HTTP(S) origins permitted to serve covers from private or special-use networks, for example http://covers.lan:8080. Scheme, hostname, and port must match; paths are rejected. Redirect targets are checked independently.
API_SECRET_KEYYes—Secret for third-party token encryption. Generate with openssl rand -hex 32; published placeholders and low-diversity values are rejected at startup.
BETTER_AUTH_SECRETYes—Signs Better Auth session cookies (min 32 chars). Generate with openssl rand -base64 32; published placeholders and low-diversity values are rejected at startup. Deliberately separate from API_SECRET_KEY with no fallback — the server refuses to start without it, and changing it signs out every user.
BETTER_AUTH_URLConditional—Required when NODE_ENV=production — the server refuses to boot without it. Public origin of the app (e.g. https://libris.example.com): scheme and host only, no path, query or credentials. Better Auth does not infer an https origin behind a TLS-terminating proxy — it reads the container's plain-http socket address, so leaving this unset makes every browser sign-in fail with 403 INVALID_ORIGIN. Optional in development, where the request origin is already correct.
PORTNo3000Port the API server listens on
LIBRIS_COOKIE_SECURENo1Set to 0 only for a plain-HTTP deployment. This controls the auth cookie's Secure attribute independently of NODE_ENV; disabling it weakens transport protection.
MIGRATIONS_PATHNo./migrationsPath to database migration files. Used by runMigrations() in bootstrap.ts to auto-apply migrations on startup.
TRUST_PROXY_HEADERSNo0Set to 1 to accept forwarded client-IP headers and the forwarded scheme (x-forwarded-proto, used in origin checks) only when the immediate TCP peer matches LIBRIS_TRUSTED_PROXIES.
LIBRIS_TRUSTED_PROXIESConditional—Comma-separated exact proxy IPs or narrow CIDRs, required when TRUST_PROXY_HEADERS=1. Forwarded chains are walked right-to-left; client-controlled entries before the first untrusted hop are ignored.
LOG_LEVELNoinfoMinimum log level: trace, debug, info, warn, error, fatal. Gates the Pino transport, which is what production and both E2E harnesses use. An interactive NODE_ENV=development terminal gets the pretty-terminal transport instead; E2E_TEST=1 opts back out of it so CI logs stay machine-readable. Only NODE_ENV=test disables logging.
TEST_ROUTE_TOKENNo—Required at 32+ characters to use test-support routes when NODE_ENV=test or E2E_TEST=1. Sent as X-Test-Token to authenticate __test/; without it every request is refused. Never set in production.
LIBRIS_HTTP_HEADERS_TIMEOUT_MSNo10000Maximum time to receive complete HTTP headers.
LIBRIS_HTTP_REQUEST_TIMEOUT_MSNo30000Maximum time to receive an entire HTTP request, including its body.
LIBRIS_HTTP_IDLE_TIMEOUT_MSNo30000Maximum inactivity period for an HTTP connection.
LIBRIS_MAX_UPLOAD_BYTESNo1073741824Aggregate limit for one POST /api/inbox/upload request in bytes, across every file and form field (default 1 GiB — ten files at the 100 MiB per-file cap, or more when they are smaller, up to the file-count cap below). A larger declared Content-Length is refused immediately; a chunked body is cut off at the ceiling as it streams.
LIBRIS_MAX_UPLOAD_FILESNo20Maximum number of file parts in one POST /api/inbox/upload request. A batch with more parts is refused with 413 before any file is written.
LIBRIS_MAX_EMBED_OPF_BYTESNo1048576Largest OPF document the organize worker will rewrite when embedding approved metadata. Larger documents are left as-is and the book is organized without embedded metadata.
LIBRIS_EMBED_TIMEOUT_MSNo30000How long the organize worker waits for the EPUB rewrite worker thread before terminating it and continuing without embedded metadata.

Rate Limiting ​

Defaults are tuned for LAN/VPN deployments — generous enough that normal use (OPDS browsing, API-key management, the frontend's polling) never trips them. If exposing Libris publicly, lower these to something like 100/10/5 for general/auth/key-creation. See Architecture › Rate Limiting for the per-endpoint tier mapping.

VariableRequiredDefaultPurpose
LIBRIS_RATELIMIT_GENERAL_LIMITNo600Max requests per window for general API/OPDS/kosync traffic.
LIBRIS_RATELIMIT_GENERAL_WINDOW_SECONDSNo60Window size in seconds for the general tier.
LIBRIS_RATELIMIT_AUTH_LIMITNo30Max credential-input requests per window (/kosync/users/auth, credential creation).
LIBRIS_RATELIMIT_AUTH_WINDOW_SECONDSNo60Window size in seconds for the auth tier.
LIBRIS_RATELIMIT_KEY_CREATION_LIMITNo30Max credential-creation requests per window (stacks with auth).
LIBRIS_RATELIMIT_KEY_CREATION_WINDOW_SECONDSNo3600Window size in seconds for the keyCreation tier.

/api/auth/* is limited by Better Auth, not by these ​

None of the variables above apply to Better Auth's own endpoints — sign-in, sign-out, password and email changes, the admin plugin, and API-key management. Better Auth rate-limits that prefix itself, with much tighter per-endpoint windows than a shared tier can express (three requests per ten seconds on sign-in and password change). The app's limiter stands aside for the whole prefix so the two budgets cannot stack and produce a 429 that neither one explains.

Those counters live in the same Redis as sessions, so they survive a restart. There is nothing to configure — to change them, edit rateLimit in services/api-hono/src/lib/auth.ts.

What the app's own tiers still cover:

TierApplies to
auth/kosync/users/auth — KOReader speaks its own protocol, outside Better Auth's reach
keyCreationPOST /api/setup and POST /api/app-passwords — each costs a password hash
generalevery path except Better Auth's separately limited /api/auth/* — /api/health and /api/health/live included

DATABASE_URL is still read as an escape hatch. When set, src/lib/resolve-database-url.ts returns it verbatim and it takes precedence over the split POSTGRES_* vars. When unset, the app assembles the connection URL from the split vars above. This gives docker-compose and the app a single source of truth in dev (compose interpolates the same POSTGRES_* values; see docker-compose.dev.yml for the ${POSTGRES_USER:-libris} interpolation), while CI and tests can override with a single DATABASE_URL.

REDIS_URL is no longer read — the Redis connection is always assembled from the REDIS_* split vars by src/lib/resolve-redis-url.ts.

The rate-limit cache uses an in-memory KV store in development (no Redis connection needed for rate limiting), but limits remain enabled. BullMQ and the ingestion workers still require Redis in development — only NODE_ENV=test stubs out the queues. Run a Redis instance locally (or via docker-compose.dev.yml) when developing.

OpenTelemetry ​

Libris supports exporting logs and traces via OpenTelemetry. The OTel SDK activates when OTEL_EXPORTER_OTLP_ENDPOINT is set. All configuration uses standard OTel environment variables — no custom Libris-specific vars are needed.

VariableRequiredDefaultPurpose
OTEL_EXPORTER_OTLP_ENDPOINTNo—OTLP collector URL (e.g., http://alloy:4318). Enables OTel.
OTEL_SERVICE_NAMENolibrisService name in telemetry data
OTEL_EXPORTER_OTLP_PROTOCOLNohttp/protobufProtocol: http/protobuf, http/json, or grpc
OTEL_TRACES_EXPORTERNootlpTrace exporter. Set to none to disable traces.
OTEL_LOGS_EXPORTERNootlpLog exporter. Set to none to disable log export.
OTEL_METRICS_EXPORTERNootlpMetrics exporter. Set to none to disable metrics.

When OTEL_EXPORTER_OTLP_ENDPOINT is not set, the OTel SDK does not initialize and adds zero overhead. Logs still flow to stdout via Pino regardless of OTel configuration.

The SDK starts with @opentelemetry/auto-instrumentations-node, so a request produces spans for the HTTP server (Hono runs on node:http), Postgres and Redis calls. @opentelemetry/instrumentation-fs and -dns are disabled: they emit one span per syscall and would bury the request path. While a span is active the logger adds trace_id and span_id to each record, which is what lets a trace backend link a span to its log lines.

Frontend (Vue SPA) ​

In production, the SPA is served by the Hono backend from the same origin — no frontend-specific configuration is needed. API requests use relative URLs (/api/*).

In development, the Vite dev server runs on port 3100 and proxies /api/* requests to the backend on port 3000 via its built-in proxy.

Frontend configuration ​

The SPA reads two configuration values: wsBaseUrl (base URL for the realtime WebSocket) and docsUrl (link to the documentation site, surfaced as a sidebar nav item when set). They resolve in two stages in apps/web/src/main.ts:

  1. Build-time defaults from VITE_* env vars baked into the bundle.
  2. Runtime overrides from a /config.json file fetched at startup. Any keys it returns override the build-time defaults, so a single static asset can reconfigure an already-built image.
VariableStageDefaultPurpose
VITE_WS_BASE_URLBuild-time"" (same-origin WebSocket)Base URL for the realtime WebSocket at /api/events. Empty means derive it from window.location.
VITE_DOCS_URLBuild-timehttps://docs.libris.raz.wtfDocumentation site URL shown in the sidebar.

At runtime, an optional /config.json served alongside the SPA can override either value:

json
{ "wsBaseUrl": "wss://books.example.com", "docsUrl": "https://docs.example.com" }

If /config.json is absent or unreadable, the build-time defaults apply.

Metadata Sources ​

The Hardcover API token for metadata enrichment is stored in the database via Settings > Connections, not as an env var. It is optional — if not configured, external metadata enrichment is skipped during ingestion.

SourceKey typeWhere to get it
HardcoverBearer tokenhardcover.app/account/api

Test Environment ​

VariableValuePurpose
NODE_ENVdevelopmentExplicitly selects source-server behavior. Test support still requires E2E_TEST=1; development alone does not mount __test/ routes.
LIBRIS_COOKIE_SECURE0Allows the browser test suite to use auth cookies over its local HTTP origin.
POSTGRES_HOSTlocalhostIsolated test DB
POSTGRES_PORT5433Test Postgres port (non-standard to avoid clashing with dev)
POSTGRES_USERlibris_test
POSTGRES_PASSWORDlibris_test
POSTGRES_DBlibris_test
REDIS_HOSTlocalhostIsolated test Redis
REDIS_PORT6380Test Redis port (non-standard)
LIBRIS_INBOX_PATH/tmp/libris-e2e/inboxEphemeral inbox
LIBRIS_LIBRARY_PATH/tmp/libris-e2e/libraryEphemeral library
E2E_TEST1Enables authenticated __test/ support routes and relaxes rate limits. Cannot be used with NODE_ENV=production.
TEST_ROUTE_TOKENtest-only secretDedicated secret sent as X-Test-Token for every __test/ request. Must be at least 32 characters.
API_SECRET_KEYthrowaway 32-byte hexStill validated in test: placeholders and low-diversity strings are rejected at startup, so it cannot be a filler value.
BETTER_AUTH_SECRETthrowaway 32-byte hexSame validation. The API refuses to boot without it, so the E2E stack does not start if it is missing from .env.test.
E2E_ADMIN_USER_ID(auto-generated)Set by global-setup.ts, not user-provided. The admin's users.id, used for seeding books.created_by and per-user rows.
E2E_REGULAR_USER_ID(auto-generated)The non-admin user's users.id.
E2E_API_KEY(auto-generated)The admin's app password, minted via POST /api/app-passwords after global-setup.ts bootstraps the account through POST /api/setup.
E2E_USER_API_KEY(auto-generated)The non-admin user's app password, for the multi-user tests.
E2E_ADMIN_COOKIE(auto-generated)The admin's replayable session cookie. Needed because app passwords are refused on admin routes, /api/auth/*, /api/app-passwords and /api/credentials — a spec driving those must authenticate the way a browser does.
E2E_USER_COOKIE(auto-generated)The non-admin user's session cookie, for the same reason.

Rate limiting is disabled only when the explicit E2E_TEST=1 switch is set (see src/middleware/rate-limit.ts), so NODE_ENV does not silently alter request protection. Ordinary development remains limited using the in-memory store.

Every __test/ request must carry the dedicated X-Test-Token. Outside NODE_ENV=test or E2E_TEST=1, the router is not registered and these paths return 404 regardless of the token.

The paths and ports above mirror .env.test.example (the dev-server mode template); the Docker mode (./scripts/test-e2e.sh) sets its own env vars and ignores that file. See .env.test.example for the complete template.