Skip to content

Production Deployment

Container Image

A single unified image is published to the Forgejo container registry via manual workflow dispatch. It contains both the Hono API server and the Vue 3 SPA frontend built with Vite+ (Rolldown-Vite) — Hono serves the static files directly. The image is built on the node:26-slim base.

ImagePortDescription
registry.example.com/user/libris3000Hono API + SPA (unified)

Tags:

  • v<api-version>-web<web-version> — composite tag from both package versions (e.g., v0.17.1-web2.10.1). Either version changing produces a new tag. Bumped via changesets — run pnpm changeset to describe a change, and the Publish Images workflow runs changeset version to bump.
  • latest — most recent build

Building the image

Trigger the Publish Images workflow from the Forgejo UI: Actions > Publish Images > Run workflow. It builds the unified image from Dockerfile at the current HEAD.

Pulling the image

bash
docker login registry.example.com
docker pull registry.example.com/user/libris:latest

Deployment Model

The SPA and API are served from the same origin — no CORS configuration needed. The auth cookie works automatically since both are on the same host.

books.example.com/         → SPA (served by Hono)
books.example.com/api/*    → Hono API
books.example.com/opds/*   → Hono API (e-reader catalog)
books.example.com/kosync/* → Hono API (reading progress sync)
books.example.com/_docs/*  → Hono API (OpenAPI docs)

Environment Variables

Required

VariablePurpose
POSTGRES_HOSTPostgres host. The app assembles the connection URL from the POSTGRES_* split vars.
POSTGRES_PORTPostgres port. Optional, defaults to 5432.
POSTGRES_USERPostgres user.
POSTGRES_PASSWORDPostgres password.
POSTGRES_DBPostgres database name.
REDIS_HOSTRedis host. The app assembles the connection URL from the REDIS_* split vars.
REDIS_PORTRedis port. Optional, defaults to 6379.
REDIS_USERRedis ACL user. Optional.
REDIS_PASSWORDRedis password. Optional.
REDIS_TLSSet to 1 for rediss:// (TLS). Required by most managed Redis providers.
LIBRIS_INBOX_PATHWritable directory for uploaded book files
LIBRIS_LIBRARY_PATHWritable directory for organized book storage
API_SECRET_KEYToken/cookie encryption secret — minimum 32 characters

Optional

VariablePurpose
PORTPort the API server listens on. Default: 3000.
COOKIE_DOMAINParent domain for auth cookie (e.g., .example.com). Leave empty for same-origin.
MIGRATIONS_PATHPath to migration files directory. Default: ./migrations.
TRUST_PROXY_HEADERSSet to 1 behind a trusted reverse proxy so X-Real-IP / X-Forwarded-For drive auth logging and rate limiting. Default: 0. See Reverse Proxy below.
LOG_LEVELLog level for the production Pino logger only: trace, debug, info, warn, error, fatal. Default: info. Validated as an enum in the env schema. Does not change the OTel SDK log level.

Rate Limiting

Rate limits are configurable through the validated env schema. The defaults are sized for LAN/VPN deployments. Tighten them if you expose the server publicly.

There are three tiers, each with a request limit and a sliding window in seconds:

  • general — applies to ordinary API traffic. Defaults to 600 requests per 60 seconds.
  • auth — applies to authentication endpoints (login, setup). Defaults to 30 requests per 60 seconds.
  • keyCreation — applies to API-key creation. Defaults to 30 requests per 3600 seconds (1 hour).
VariablePurposeDefault
LIBRIS_RATELIMIT_GENERAL_LIMITMax requests per window for general API traffic.600
LIBRIS_RATELIMIT_GENERAL_WINDOW_SECONDSGeneral tier window length, in seconds.60
LIBRIS_RATELIMIT_AUTH_LIMITMax requests per window for auth endpoints.30
LIBRIS_RATELIMIT_AUTH_WINDOW_SECONDSAuth tier window length, in seconds.60
LIBRIS_RATELIMIT_KEY_CREATION_LIMITMax requests per window for API-key creation.30
LIBRIS_RATELIMIT_KEY_CREATION_WINDOW_SECONDSKey-creation tier window length, in seconds.3600

The auth and keyCreation tiers fall back to an in-memory limiter when Redis is unavailable; the general tier fails open. Set TRUST_PROXY_HEADERS=1 behind a reverse proxy so limits key off the real client IP. See Reverse Proxy below.

OpenTelemetry

The OTEL_* variables are standard OpenTelemetry SDK environment variables. They are read directly from process.env by the OpenTelemetry NodeSDK and are not part of the Libris-validated env schema. Only OTEL_EXPORTER_OTLP_ENDPOINT and OTEL_SERVICE_NAME are referenced by otel.ts: setting OTEL_EXPORTER_OTLP_ENDPOINT is what activates the SDK (and the SDK does not start when NODE_ENV=test), and OTEL_SERVICE_NAME defaults to libris if unset. The remaining names are interpreted by the SDK itself, not by Libris.

VariablePurpose
OTEL_EXPORTER_OTLP_ENDPOINTOTLP collector URL (e.g., http://alloy:4318). Setting this activates the OTel SDK. Checked by otel.ts.
OTEL_SERVICE_NAMEService name in telemetry data. Defaults to libris (set by otel.ts when unset).
OTEL_EXPORTER_OTLP_PROTOCOLOTLP protocol: http/protobuf, http/json, or grpc. Interpreted by the OTel SDK; SDK default is http/protobuf.
OTEL_TRACES_EXPORTERTrace exporter. SDK default otlp. Set to none to disable traces.
OTEL_LOGS_EXPORTERLog exporter. SDK default otlp. Set to none to disable OTel log export (stdout Pino output is unaffected).
OTEL_METRICS_EXPORTERMetrics exporter. SDK default otlp. Set to none to disable metrics.

Volumes

The container needs persistent, writable storage for two paths:

Mount targetPurpose
Value of LIBRIS_INBOX_PATHIncoming book files (watched for ingestion)
Value of LIBRIS_LIBRARY_PATHOrganized book library

Example Docker Compose

The schema uses Postgres full-text search (tsvector GIN indexes) and trigram search on the books table. The pg_trgm extension is required and must be available in the Postgres image you run. The standard postgres:17 image ships it. A Postgres build without pg_trgm will fail migrations and search.

yaml
services:
  db:
    image: postgres:17
    restart: unless-stopped
    environment:
      POSTGRES_USER: libris
      POSTGRES_PASSWORD: changeme
      POSTGRES_DB: libris
    volumes:
      - db_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U libris"]
      interval: 10s
      timeout: 5s
      retries: 5

  redis:
    image: redis:7
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 5s
      retries: 5

  libris:
    image: registry.example.com/user/libris:latest
    restart: unless-stopped
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy
    environment:
      POSTGRES_HOST: db
      POSTGRES_PORT: "5432"
      POSTGRES_USER: libris
      POSTGRES_PASSWORD: changeme
      POSTGRES_DB: libris
      REDIS_HOST: redis
      REDIS_PORT: "6379"
      LIBRIS_INBOX_PATH: /data/inbox
      LIBRIS_LIBRARY_PATH: /data/library
      API_SECRET_KEY: # openssl rand -hex 32
    volumes:
      - inbox:/data/inbox
      - library:/data/library
    ports:
      - "3000:3000"

volumes:
  db_data:
  inbox:
  library:

Reverse Proxy

By default, Libris ignores X-Forwarded-For and X-Real-IP for auth logging and rate limiting and uses the real TCP peer address instead. When running behind a trusted reverse proxy (nginx, Caddy, Traefik), set TRUST_PROXY_HEADERS=1 and configure the proxy to pass the client IP headers:

nginx:

nginx
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

Without this, repeated auth failures from the same client may not be correctly rate-limited.

WebSocket Upgrade

The SPA receives real-time updates over a single WebSocket at /api/events. The reverse proxy must allow the WebSocket upgrade on /api/* by forwarding the Connection and Upgrade headers. Without it, the connection falls back or fails and live UI updates stop working.

nginx:

nginx
location /api/ {
    proxy_pass http://libris:3000;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
}

Caddy and Traefik proxy WebSocket upgrades automatically; no extra configuration is needed for the upgrade itself.

Database Migrations

Migrations apply automatically on API startup — runMigrations() in services/api-hono/src/bootstrap.ts runs before the server starts accepting requests. No manual migration step is needed when deploying a new image. The migrations directory is resolved from MIGRATIONS_PATH (default ./migrations, which the Dockerfile copies into the image alongside the bundled server).