Skip to content

Installation Guide

How to put Bookend into a bank’s network, from the release bundle to an activated install that specialists can sign in to. This guide is the end-to-end path; the runbooks in the bundle’s docs/runbooks/ folder are the checklists you keep open on the day (install.md, upgrade.md, backup-restore.md, incident.md, resilience.md).

Bookend is on-premises only. It ships as Docker images and runs on one Docker host inside the bank’s network. Nothing is cloud-hosted, nothing phones home except the optional metering heartbeat, and no loan data ever leaves the bank.

Container Image Purpose Exposed
app-ui bookend/app-ui nginx serving the React SPA and proxying /api/* and /mcp to the api yes, the only service a bank publishes
api bookend/api ASP.NET Core minimal API: authentication, settings, intake, pipeline orchestration, rules, review, boarding, evidence, MCP, jobs internal
migrator bookend/migrator applies the release’s DDL scripts once and exits; the api only starts after it succeeds internal, one-shot
inference bookend/inference document split, classify, OCR (Tesseract 5), extract and locate service (:9090) internal
db postgres:16-alpine bundled database (optional: an existing Postgres 16+ or SQL Server 2019+ can be used instead) internal
jxchange-mock bookend/jxchange-mock stand-in for a Jack Henry core, for evaluation evaluation only
metering-mock bookend/jxchange-mock the same mock image answering metering heartbeats inside the stack evaluation only
mailhog mailhog/mailhog evaluation SMTP server and mailbox; a bank uses its own relay evaluation only

Everything is defined in deploy/compose.yml. As shipped, that file is the evaluation stack: it starts the three evaluation fixtures, makes api wait for them, and publishes the database (5432) and api (8080) ports next to the UI. A production install adds a small override file (section 4) that withdraws those ports and moves the fixtures behind a profile, so none of them start.

Persistent data lives in named volumes (Compose prefixes them with the project name, bookend_): dbdata (bundled Postgres), documents (/data/documents, content-addressed PDFs and page renders), exports (/data/exports, boarding files and wire requests), watch (/data/watch, transient intake drop folder), modelcache (inference scratch, rebuildable).

Item Requirement
Host Linux x86-64 (Ubuntu 22.04/24.04 or RHEL 9 family). Windows Server with Docker Desktop (WSL 2 backend) works for evaluation only.
Sizing Pilot: 4 vCPU / 16 GB / 100 GB SSD. Reference: 16 vCPU / 64 GB / 500 GB SSD (500 loans and 150,000 pages a year, 10 concurrent reviewers). No GPU.
Docker Engine 24+ with Compose v2.24.4 or later (docker compose version); the production override uses Compose’s !reset and !override tags.
Database bundled postgres:16-alpine, or Postgres 16+ / SQL Server 2019+ that the bank operates. Two principals: an owner (runs DDL and grants) and bookend_app (DML only).
Network Inbound: 443 to app-ui (via the bank’s load balancer or a TLS-bearing nginx override). Outbound from api: SMTP relay, core endpoint (jXchange) and, unless air-gapped, the metering endpoint. See the note below.
Mail An SMTP relay reachable from the host (host, port, TLS mode, optional credentials). Sign-in links, invitations, password resets, loan assignments and escalations go through it.
Time NTP-synchronized host; evidence timestamps, token lifetimes and the nightly jobs depend on it.
Browsers Current Chrome, Edge or Firefox; 1366 × 768 minimum.

Two further outbound connections exist only if an administrator configures them: single sign-on against the bank’s OpenID Connect identity provider (auth.oidc_* settings), and extraction-template suggestions from an OpenAI-compatible model endpoint (ai.endpoint; a local model inside the bank’s network works). Both are off by default.

A release arrives as bookend-<version>.tar.gz with a .sha256 file next to it. Unpacked, it contains:

Path What it is
bookend-<version>-images.tar the five Bookend images (api, app-ui, inference, migrator, jxchange-mock), tagged <version>
deploy/ compose.yml, .env.example and supporting files
db/postgres/, db/sqlserver/ the release’s numbered DDL and seed scripts for each dialect
docs/ these guides, the runbooks and the OpenAPI document (openapi.json)
validation-pack/ golden packages (golden-packages/, with their manifest), one page per rule, and the accuracy report when present
CHANGELOG.md release notes
SHA256SUMS, SHA256SUMS.minisig checksums of every file, and their signature
VERIFY.md the verification steps below

Verify before loading anything:

Terminal window
sha256sum -c bookend-<version>.tar.gz.sha256
tar -xzf bookend-<version>.tar.gz && cd bookend-<version>
sha256sum -c SHA256SUMS
minisign -V -P <bookend-release-public-key> -m SHA256SUMS # the key comes with your implementation agreement
docker load -i bookend-<version>-images.tar
docker image ls | grep "bookend/.*:<version>"

A bundle without SHA256SUMS.minisig was not produced by the release pipeline; do not install it without confirming its origin with Bookend.

The bundle carries the Bookend images only. The stock postgres:16-alpine image (bundled database) and, for an evaluation, mailhog/mailhog:v1.0.1 come from Docker Hub or the bank’s registry mirror.

Bookend keeps all runtime configuration in its own settings table, encrypted where secret. Only the handful of values needed to reach that table live outside it, in deploy/.env:

Terminal window
cp deploy/.env.example deploy/.env
chmod 600 deploy/.env
Variable Set it to
DB__PROVIDER postgres (default) or sqlserver
POSTGRES_DB / POSTGRES_USER / POSTGRES_PASSWORD bundled database name and owner principal (the migrator connects as this). Replace the example password.
DB_APP_USER / DB_APP_PASSWORD the least-privilege application principal; the migrator’s grants script creates it with exactly this password. Replace the example password.
DB__CONNECTION / DB__MIGRATOR_CONNECTION Npgsql or SqlClient connection strings. With the bundled database, compose.yml builds both from the values above with host db and ignores these; for an external database see below
BOOKEND_MASTER_KEY base64 of 32 random bytes (openssl rand -base64 32). Encrypts every secret in settings: SMTP password, core credentials, the RS256 signing key. Store it in the bank’s vault. Losing it means re-entering every secret and re-issuing every token. The example value must never be used.
BOOKEND_VERSION the release version, exactly as the loaded images are tagged (without it Compose looks for the tag dev)
BOOKEND_SKIP_SCRIPTS 908_seed_demo_users.sql on every production install. The shipped scripts include two demo user accounts for evaluation; this variable makes the migrator leave that script out and tells the api’s schema check not to expect it.
BOOKEND_UI_PORT host port for app-ui (default 8081; 443 is normally the load balancer’s)
Proxy__TrustedNetworks comma-separated CIDR list of the proxies allowed to set X-Forwarded-*; default is the private ranges plus loopback. Set it to the load balancer’s network

.env is read by Compose (env_file plus ${...} interpolation). Values set under a service’s environment: in a compose file win over the same name in .env; that is why the connection strings and the login rate limit are set in the override files below rather than in .env.

Create deploy/compose.production.yml next to compose.yml:

# Production: publish app-ui only, keep the evaluation fixtures off, production login limit, release license key.
services:
db:
ports: !reset []
api:
ports: !reset []
environment:
RateLimiting__LoginAttemptsPerMinute: '10'
Licensing__PublicKeyPath: /run/bookend/license-public.pem
volumes:
- ./license-public.pem:/run/bookend/license-public.pem:ro
depends_on: !override
migrator:
condition: service_completed_successfully
inference:
condition: service_healthy
jxchange-mock:
profiles: [evaluation]
metering-mock:
profiles: [evaluation]
mailhog:
profiles: [evaluation]
  • ports: !reset [] withdraws the database and api ports that compose.yml publishes for evaluation.
  • RateLimiting__LoginAttemptsPerMinute returns the per-address login limit to the production default of 10; the evaluation compose raises it to 60 (BOOKEND_LOGIN_ATTEMPTS_PER_MINUTE) for automated test runs.
  • Licensing__PublicKeyPath points the api at Bookend’s license-signing public key, a PEM file Bookend provides with your license; save it as deploy/license-public.pem. Without it the api verifies license keys against a development key that production licenses are not signed with.
  • depends_on: !override removes the evaluation fixtures from the api’s start conditions, and the evaluation profile keeps them from starting.

Every command in this guide then uses both files: docker compose -f deploy/compose.yml -f deploy/compose.production.yml ....

Create the database and the owner principal, then create deploy/compose.external-db.yml:

services:
db:
profiles: [bundled-db] # the bundled Postgres no longer starts
migrator:
depends_on: !reset {}
environment:
DB__CONNECTION: ${DB__CONNECTION}
DB__MIGRATOR_CONNECTION: ${DB__MIGRATOR_CONNECTION}
api:
environment:
DB__CONNECTION: ${DB__CONNECTION}

and set DB__CONNECTION (application principal) and DB__MIGRATOR_CONNECTION (owner) in deploy/.env to the bank’s instance. POSTGRES_PASSWORD and DB_APP_PASSWORD must still be set, because compose.yml requires them. Add -f deploy/compose.external-db.yml to every command.

The migrator reads the application principal’s user name and password from DB__CONNECTION and creates it if it does not exist, then grants DML. The user name must be a plain identifier (letters, digits, underscore).

  • Postgres: the owner needs CREATEROLE (or a DBA pre-creates the application role). evidence_events, auth_events, finding_actions and audit_log are append-only for the application principal (REVOKE UPDATE, DELETE, TRUNCATE), and it cannot write the schema_versions ledger.
  • SQL Server: set DB__PROVIDER=sqlserver. The grants script creates a login and a database user; the owner needs permission to create logins (or a DBA pre-creates the login). The same tables are protected with DENY.

Both dialects ship the same numbered scripts under db/postgres/ and db/sqlserver/, and both are applied against real database containers in Bookend’s automated test suite.

Terminate TLS in front of app-ui:8080 at the bank’s load balancer (recommended) or mount certificates and a listen 443 ssl server block through an nginx override. The proxy must pass X-Forwarded-For and X-Forwarded-Proto; the api honors them only from Proxy__TrustedNetworks, so per-address login limits and audit rows show real client addresses. Keep app.public_url (wizard step 5) equal to the URL users type, because sign-in links are built from it.

The inference service reads Ocr__* variables. Add them under an inference: entry with environment: in your production override only if you need to change them:

Variable Default Meaning
Ocr__Enabled true off means scanned pages are reported but not recognized
Ocr__Languages eng Tesseract language list, +-separated; the image ships English only
Ocr__Dpi 300 rasterization resolution for scanned pages
Ocr__TimeoutSeconds 120 ceiling per page; a hung Tesseract process is killed and the pipeline stage retried
Ocr__ThreadLimit keep at 1 OpenMP threads per Tesseract process. More threads per process starve each other on shared cores; throughput comes from recognizing pages in parallel instead
Ocr__MaxConcurrentPages 0 (automatic) pages recognized at once; automatic means processor count ÷ Ocr__ThreadLimit, at least 1 and at most 8

On the api side, Inference__TimeoutSeconds (default 600) is the envelope for one call to the inference service; a whole scanned document is recognized in one call, which takes minutes on a small host.

Terminal window
docker compose -f deploy/compose.yml -f deploy/compose.production.yml up -d --wait
docker compose -f deploy/compose.yml -f deploy/compose.production.yml ps
docker compose -f deploy/compose.yml -f deploy/compose.production.yml logs migrator

What happens, in order:

  1. db starts and becomes healthy (skipped for an external database).
  2. migrator runs: takes a database lock, applies every pending script in its own transaction, records each in schema_versions with a checksum, logs the scripts it applied and exits 0. It refuses to run (exit 4) on drift: an applied script whose file changed, or an applied script that is no longer present. A missing or malformed bootstrap variable is exit 2.
  3. inference starts and reports healthy on /healthz.
  4. api starts only after the migrator succeeded: it validates the bootstrap variables (exit 2 naming the missing one), generates the RS256 signing key on first boot and stores it encrypted, registers the scheduled jobs, and serves on :8080. Its container health check is /readyz, which reports unhealthy (and lists the missing scripts under schema) if the ledger does not contain every script this build requires.
  5. app-ui starts once api is healthy and serves on :8080 inside the network (published as BOOKEND_UI_PORT).

GET /readyz on the api (through the UI: https://<host>/api/readyz) returns { status: "healthy", version, checks: { database, schema } } when everything is in place; each check carries its own status, description and duration.

A clean database has no administrator. Opening https://<host>/ lands on /setup, the ten-step onboarding wizard; every other route stays gated until step 10 activates the install. Each step is saved as you go and can be revisited; nothing is written to files.

Step What you enter What Bookend does
1 First administrator: email, display name, password (at least 12 characters, the default minimum) Creates the account and signs you in. Only possible while no administrator exists.
2 Institution: name, charter number, ABA routing number (nine digits), address Shown on evidence packets, wire requests and emails.
3 Database preflight Times a ledger read and a settings write and delete as the application principal; shows the dialect, the number of applied scripts and the latency.
4 SMTP: host, port, TLS mode (none / starttls / ssl), optional username and password, from address and name; Send test email Sends a real message through the relay. Activation is blocked until a test has succeeded.
5 Sign-in policy: public URL, access-token minutes (1 to 60), refresh-token hours (1 to 720), sign-in link lifetime, password minimum length (8 to 128), password and email-link sign-in on or off, allowed email domains Stored under auth.* and app.public_url; the URL is what sign-in links point at. At least one sign-in method must stay on.
6 Core adapter: file.export (export folder) or jackhenry.jxchange (endpoint, username, password, institution routing id); Test connection File export checks that the folder is writable by the api; jXchange calls the endpoint’s ping. Activation is blocked until a test has succeeded.
7 Document sources: watch folder (default /data/watch), maximum package size (MB, default 200), retention (days, default 2,555, about seven years) A changed watch folder takes effect after an api restart. The folder is scanned every 60 seconds by default; a LOANREF/ folder of PDFs (plus lar.json) is ingested once its files are quiet.
8 LAR mapping: choose the profile that maps the bank’s approval record onto the canonical fields; preview it against a sample. You can skip this step and finish it later lar-json-v1 is seeded for the canonical JSON; other formats are new profile versions (Administration Guide).
9 Licensing and metering: license key, metering endpoint, or Air-gapped installation The key is verified offline against the configured public key; a blank key runs as an unlicensed evaluation. Heartbeats carry { install_id, version, period, closed_loan_count, document_page_count, health, license_key, generated_at }, never loan data. Air-gapped turns heartbeats off.
10 Review and activate Refuses (422, listing the blockers) until steps 1 to 6 are complete and both tests passed; then every route opens and the first heartbeat is sent (unless air-gapped).

The metering endpoint field is prefilled with the in-stack evaluation receiver (http://metering-mock:8090/v1/heartbeat); replace it with the endpoint Bookend gives you with your license, or tick air-gapped.

An administrator can reopen the wizard later (Settings → Other options → Reopen the setup wizard, or POST /v1/setup/reopen) without losing anything; it gates routes again until re-activated. Every value is also editable afterward in Settings.

  1. https://<host>/api/readyz is healthy. System → Diagnostics (administrators) shows version, database and schema scripts, license, metering status, the inference probe, core provider, the last evidence sweep, health entries and every scheduled job with its next fire time.
  2. Upload the validation pack’s clean package (validation-pack/golden-packages/BK-DEMO-001, nine PDFs plus lar.json) through Loans → New loan. The pipeline runs split → classify → OCR → extract → locate; the loan reaches review with no findings and every value has a page-backed provenance chip.
  3. Upload the scanned copy (BK-DEMO-004): the OCR stage reports Tesseract and its confidence, and the same values come back labeled OCR with provenance on the scanned pages, which proves the OCR path works on this host.
  4. Sign out and request an email sign-in link. It arrives through the bank’s relay and is single-use.
  5. System → Metering & license → Send heartbeat now (or confirm air-gapped) and note the install id for support.
  6. Create the operational users: Users & access → invite with roles specialist, manager, boarding_checker, auditor_readonly as needed (Administration Guide, users and roles). Invitations are single-use email links valid for 72 hours.

The golden packages are fictitious loans. Reject them from review once the checks pass so they do not sit in the queue.

  • Credentials: the master key and database passwords are in the vault; deploy/.env is readable only by the service account; BOOKEND_SKIP_SCRIPTS=908_seed_demo_users.sql was set before the first start, so the two evaluation user accounts do not exist. If the stack was ever started without it, deactivate those accounts in Users & access.
  • Network: only app-ui is published; the api, database and inference ports are not reachable from outside the Docker network. Confirm with docker compose ... ps (only app-ui shows a host port) and the host firewall.
  • Proxy trust: Proxy__TrustedNetworks names only the load balancer; otherwise a client could forge X-Forwarded-For.
  • Rate limits: 10 login attempts a minute per address and 300 requests a minute per principal on /v1 are the production defaults; change them only with security sign-off.
  • Backups: schedule the backup runbook (backup-restore.md: database dump, documents and exports volumes, .env) before the first real loan; rehearse a restore quarterly.
  • Jobs: the nightly evidence sweep (02:00 UTC), the retention sweep (04:00 UTC) and the daily heartbeat (03:15 UTC, plus one shortly after each start) are persisted jobs, visible in Diagnostics; a missed run fires on the next start.
  • Air-gapped: tick air-gapped in wizard step 9 (metering.air_gapped) and generate the signed quarterly usage report instead of heartbeats (Administration Guide, metering).
  • Upgrading a release: upgrade.md. Load the new images, set BOOKEND_VERSION, up -d --wait; the migrator applies the new scripts.
  • Backups and restore: backup-restore.md.
  • Alerts and first commands: incident.md.
  • What fails, what recovers, and the restore drill: resilience.md.
Where Port / path Notes
app-ui :8080 in the network, published as BOOKEND_UI_PORT (8081) SPA, /api/* → api (prefix stripped), /mcp → api, /nginx-health
api :8080 /healthz, /readyz, /v1/*, /mcp, /scalar/v1, /v1/openapi.json, /v1/.well-known/jwks.json. compose.yml publishes it as BOOKEND_API_PORT (8080); the production override withdraws it
inference :9090 /healthz, /v1/split, /v1/classify, /v1/ocr, /v1/extract, /v1/locate, /v1/read-region
db :5432 bundled Postgres. compose.yml publishes it as BOOKEND_DB_PORT (5432); the production override withdraws it
jxchange-mock :8090 evaluation core; metering-mock runs the same image as the evaluation metering receiver
mailhog :8025 UI (published as BOOKEND_MAILHOG_PORT), :1025 SMTP evaluation only
api container /data/documents, /data/exports, /data/watch named volumes, owned by the app user (uid 1654)