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.
1. What you are installing
Section titled “1. What you are installing”| 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).
2. Prerequisites
Section titled “2. Prerequisites”| 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. |
| 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.
3. Obtain and verify the release
Section titled “3. Obtain and verify the release”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:
sha256sum -c bookend-<version>.tar.gz.sha256tar -xzf bookend-<version>.tar.gz && cd bookend-<version>sha256sum -c SHA256SUMSminisign -V -P <bookend-release-public-key> -m SHA256SUMS # the key comes with your implementation agreementdocker load -i bookend-<version>-images.tardocker 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.
4. Configure the bootstrap environment
Section titled “4. Configure the bootstrap environment”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:
cp deploy/.env.example deploy/.envchmod 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.
The production override
Section titled “The production override”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 thatcompose.ymlpublishes for evaluation.RateLimiting__LoginAttemptsPerMinutereturns 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__PublicKeyPathpoints the api at Bookend’s license-signing public key, a PEM file Bookend provides with your license; save it asdeploy/license-public.pem. Without it the api verifies license keys against a development key that production licenses are not signed with.depends_on: !overrideremoves the evaluation fixtures from the api’s start conditions, and theevaluationprofile keeps them from starting.
Every command in this guide then uses both files: docker compose -f deploy/compose.yml -f deploy/compose.production.yml ....
External database
Section titled “External database”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_actionsandaudit_logare append-only for the application principal (REVOKE UPDATE, DELETE, TRUNCATE), and it cannot write theschema_versionsledger. - 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 withDENY.
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.
OCR tuning (optional)
Section titled “OCR tuning (optional)”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.
5. Start the stack
Section titled “5. Start the stack”docker compose -f deploy/compose.yml -f deploy/compose.production.yml up -d --waitdocker compose -f deploy/compose.yml -f deploy/compose.production.yml psdocker compose -f deploy/compose.yml -f deploy/compose.production.yml logs migratorWhat happens, in order:
dbstarts and becomes healthy (skipped for an external database).migratorruns: takes a database lock, applies every pending script in its own transaction, records each inschema_versionswith 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.inferencestarts and reports healthy on/healthz.apistarts 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 underschema) if the ledger does not contain every script this build requires.app-uistarts onceapiis healthy and serves on:8080inside the network (published asBOOKEND_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.
6. Onboard with the wizard
Section titled “6. Onboard with the wizard”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.
7. Verify the install
Section titled “7. Verify the install”https://<host>/api/readyzis 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.- Upload the validation pack’s clean package (
validation-pack/golden-packages/BK-DEMO-001, nine PDFs pluslar.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. - 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. - Sign out and request an email sign-in link. It arrives through the bank’s relay and is single-use.
- System → Metering & license → Send heartbeat now (or confirm air-gapped) and note the install id for support.
- Create the operational users: Users & access → invite with roles
specialist,manager,boarding_checker,auditor_readonlyas 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.
8. Harden before go-live
Section titled “8. Harden before go-live”- Credentials: the master key and database passwords are in the vault;
deploy/.envis readable only by the service account;BOOKEND_SKIP_SCRIPTS=908_seed_demo_users.sqlwas 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-uiis published; the api, database and inference ports are not reachable from outside the Docker network. Confirm withdocker compose ... ps(onlyapp-uishows a host port) and the host firewall. - Proxy trust:
Proxy__TrustedNetworksnames only the load balancer; otherwise a client could forgeX-Forwarded-For. - Rate limits: 10 login attempts a minute per address and 300 requests a minute per principal on
/v1are the production defaults; change them only with security sign-off. - Backups: schedule the backup runbook (
backup-restore.md: database dump,documentsandexportsvolumes,.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).
9. Day-2 pointers
Section titled “9. Day-2 pointers”- Upgrading a release:
upgrade.md. Load the new images, setBOOKEND_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.
Appendix: ports and paths
Section titled “Appendix: ports and paths”| 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) |