Deployment & Operations

Self-hosting that was designed for the first hour

Java 25 on Spring Boot 4, a PostgreSQL application database, Flyway migrations resolved by name so a GraalVM native image installs identically. An eight-step wizard that writes nothing until you press Confirm, generates your systemd unit, and never reads a secret back — because the endpoint is reachable before authentication.

Runtime

Java 25 · Spring Boot 4

  • GraalVM native-image capable
  • PostgreSQL application database
  • Flyway-managed schema, numbered 001.sql
  • Migrations resolved by name, not by classpath scanning
  • So a native image installs the schema identically to a JVM build
  • ZooKeeper / Curator for clustering
  • Server-rendered Thymeleaf, vanilla ES modules
Realtime

WebSocket, SSE and NDJSON

  • WebSocket push of dashboard card updates
  • One Postgres NOTIFY adapter per live dashboard
  • Subscribed to a channel on the data source, not the app database
  • SSE for streaming AI chat and the MCP event stream
  • NDJSON streaming for question results
  • Chunked CSV export
  • A LISTEN control channel accepting reload signals
Air-gap friendly

No external asset requests

  • The UI fetches nothing from a third-party host
  • No CDN for fonts, scripts or styles
  • The embed SDK is served from the application itself
  • No phone-home
  • Your rows never leave your infrastructure
  • BYO LLM — including local Ollama or LM Studio
  • EU data residency because the host is yours

The eight steps

  1. Database. Point Qrly at a PostgreSQL instance and probe the connection. Read-only.
  2. Schema. Check what is there. Installing is an opt-in action that runs the idempotent migration alone and stamps nothing.
  3. Tenant. The outermost unit of the multi-tenancy model.
  4. Organisation. The unit that owns connections, collections and users.
  5. Admin account. The first superuser. There is no default account with a known password, because there is no seeded account at all.
  6. Settings. Properties management — read, save, generate a secret, download.
  7. Service. Detect the systemd unit, copy or download the unit file, install, start, stop and check status, with a manual fallback for hosts where that is not appropriate.
  8. Confirm. The only step that commits. It migrates, creates the tenant, the organisation and the superuser, then stamps the initialisation flag.

Property one: nothing is written until Confirm

Unless the operator explicitly asks for it, every step before Confirm is a read-only probe — status, check-db, check-schema. This matters because first-run setup is exactly where a half-finished attempt does the most damage: an abandoned wizard that has already created a tenant, or already stamped a migration baseline, leaves a database in a state nobody documented. Qrly's wizard leaves the database as it found it until you commit, which means you can walk through it against a production candidate to see what it says and then close the tab.

Property two: secrets are never read back

The setup endpoint is reachable before authentication. It has to be — there is no account yet. That makes any read-back of a configured secret a pre-authentication disclosure, so Qrly does not do it. Instead, each field reports one bit of information: whether the value is still the one the distribution ships. That comparison is made against the packaged properties with ${VAR:default} unwrapped, so a value inherited from an environment-variable default is judged correctly rather than by string equality on the template.

The practical result is the shipped-secret indicator: an operator can see at a glance which secrets are still at their distribution default and therefore must be changed, without the page ever disclosing a secret that has already been set. Values still at the shipped default are regenerated in the clear, precisely because they are not secret yet and you need to be able to copy the replacement.

False-history detection

There is a specific broken state that migration tooling handles badly: a baseline stamped on a database that never actually held the schema. The history table says the schema is at version N; the schema is not there at all. The usual outcome is a startup that refuses to migrate and an error message describing a version mismatch rather than the real problem.

Qrly names this state explicitly and clears it when the history records no migration of its own — and refuses with a count when it does, rather than quietly discarding real history. It is a small thing that turns an afternoon of confusion into a sentence on a screen.

Behaviour
While false Uninitialised Before the wizard is confirmed
Once true Normal operation
Page requests
Redirect to /setup
/setup redirects to login
Other API calls
503 JSON — not a redirect
Normal responses; machine clients get a real error either way
Setup endpoints
Open
The setup API returns 403
Flyway at startup
Does not migrate
Migrates normally, resolved by name
Scheduled jobs
None run at all
All fifteen run; the task is wrapped, not the future, so periodic jobs re-arm
Health endpoints
/live and /ready open
Still open — plus /metrics in Prometheus text
Direction of travel
false → true only
Persisted to the external properties file; it does not go back
Legend Behaviour Deliberately inert
01 / Config & logs

See the effective configuration, change a log level live

The config page shows active profiles, property sources, totals, system properties and environment variables — with secrets masked. That is the page you open when the answer to "which value is actually in effect" has three plausible candidates spread across a unit file, an environment and a properties file.

The log viewer is a file picker with a line count, search, level filters, auto-scroll, download and clear — plus a per-logger table showing the effective level and letting you set it live, with a reset-all. Turning up debug for one package at three in the afternoon does not require a restart, and turning it back down does not require remembering what it was.

02 / Memory

Heap gauges, GC control and history

Heap used, free, total and maximum; non-heap; collector statistics; memory pools. GC settings including auto-GC, a threshold, a schedule and the scheduler's status, plus a trigger-GC action and a GC history you can clear. System information alongside it: Java version, JVM, OS, processor count, uptime and start time.

This is the page that answers "is the box actually under pressure, or is something else wrong" without attaching a profiler to a production JVM — which, on a machine you are self-hosting for data-residency reasons, may not be a thing you want to do at all.

03 / Backups

pg_dump on a cron you can edit at runtime

Create a backup on demand; list backups with created time, status, filename, size, duration and expiry; download one; delete one. Settings cover auto-enable, the cron expression with a field helper, and retention. The dump runs asynchronously so a large database does not block the console.

The detail that matters operationally: the cron is operator-editable and takes effect without a restart. Changing a backup window at 17:00 on a Friday should not require a maintenance slot, and here it does not. Backups are installation-wide and superuser-only.

04 / Languages

Translations, hot-reloaded

The console also owns languages and translations: statistics, adding and deleting keys, listing and patching languages with a display name, an RTL flag and an active flag. Five languages ship with a full bundle — English, German, French, Dutch and Vietnamese — and the supported list is refreshed at startup from the message files that actually exist rather than from a hard-coded array.

DeepL machine translation is wired in for single, batch, missing-per-locale, missing-for-all and re-translate-with-overwrite runs, with usage metering, copy-bundled and sync-bundled actions, a live progress table and hot reload without a restart. Adding a language to a running installation is an afternoon, not a release.

What runs, and when

  1. Alerts and delivery. AlertScheduler hourly with per-cadence gating; SubscriptionScheduler daily at 06:00.
  2. Cache and materialization. MaterializationScheduler per minute for question-level caches; TableMaterializationScheduler per minute for tier-three crons; TableSyncScheduler per minute for tier-four configs and groups; QueryCacheEvictionScheduler sweeping the in-process tier every 60 seconds, cleaning the persistent tier hourly and ticking active refresh.
  3. Schema and freshness. SchemaSyncScheduler nightly; FreshnessScheduler every five minutes.
  4. Intelligence. AnomalyScheduler nightly at 03:00; ProactiveInsightScheduler hourly, honouring a per-organisation cron; ModelerEnrichmentScheduler nightly at 02:30; NarrationWorker draining every five seconds with FOR UPDATE SKIP LOCKED; WeeklyInvestigationDigestScheduler on Mondays at 07:00; RiskReportScheduler sweeping for overdue items hourly.
  5. Housekeeping. BackupScheduler ticking every minute against the operator-editable cron; AuditLogRetentionScheduler daily at 03:00; AiCallContextPurgeScheduler daily at 03:30, defaulting to 90 days.

All of them are gated until the platform is initialized. That is not a detail — it is why an uninitialised Qrly is genuinely inert rather than merely un-navigable, and why a half-configured host does not start emailing subscriptions to an address somebody typed as a test.

Lifecycle signals

Qrly listens on a configurable PostgreSQL LISTEN channel and accepts reload schema, reload config or both, on a daemon thread with exponential reconnect backoff — skipping silently when the application database is not PostgreSQL. Two Unix signals are wired as well: SIGUSR1 reloads all schema caches, and SIGUSR2 logs a config-reload request. Both are the kind of thing you want to exist the first time a deploy script needs to nudge a running node without restarting it.

Four things Qrly does not do for you

  1. PostgreSQL. Qrly needs an application database and does not run one for you. Version choice, storage, tuning, connection limits, patching and availability are yours. The product installs and migrates its own schema into that database and will tell you clearly when it cannot.
  2. Verified backups. The platform console will run pg_dump on a cron, keep the results with size, duration and expiry, and let you download them. It will not restore one for you and it cannot tell you whether a restore would succeed. A backup nobody has restored is a hypothesis. Test it on a schedule, against a real target, and record the result.
  3. TLS termination. Put a reverse proxy in front of Qrly and terminate TLS there, with your own certificate lifecycle. This also gives you a natural place for the custom domains that drive brand resolution and SSO discovery, since the hostname a user arrives on is what decides which sign-in buttons the login page offers.
  4. The encryption key. Connection credentials are AES-encrypted at rest and the query cache is AES-256-GCM encrypted in both tiers. That protection is only as good as the key, and the key is yours to store, rotate and back up separately from the database. Restoring a database without the key restores an unreadable set of credentials — which is exactly what an attacker with only the database should get, and exactly what you must not accidentally arrange for yourself. The setup wizard's shipped-secret indicator exists to make sure this key is not still the one the distribution shipped with.

Everything else — schema installation, migrations, the systemd unit, clustering coordination, the schedulers, log levels, heap visibility, translation reloads — is in the product, which is the point of a first-run wizard that generates its own service file.

What do I need to run Qrly?

A Linux host and a PostgreSQL database for the application's own schema. Qrly runs on Java 25 and Spring Boot 4 and is GraalVM native-image capable. ZooKeeper via Curator is used for clustering when you run more than one node. Everything else — the setup wizard, the schema installation, the systemd unit, the backups — is handled by the product. The UI makes no external asset requests, so the application works on a host with no outbound internet access.

Does the setup wizard change anything before I confirm?

No — nothing is written until Confirm unless you explicitly ask for it. The earlier steps are read-only probes: status, check-db, check-schema. Installing the schema is an opt-in action that runs the idempotent migration alone and stamps nothing. Only Confirm migrates, creates the tenant, organisation and superuser, and then stamps the initialisation flag.

Why can I not read a secret back out of the setup screen?

Because the setup endpoint is reachable before authentication — it has to be, since there is no account yet. Reading secrets back there would be a pre-authentication disclosure. Instead each field reports only whether the value is still the one the distribution ships, compared against the packaged properties with ${VAR:default} unwrapped. Values that are still the shipped default are regenerated in the clear so you can copy them; anything you have already set stays write-only.

What does platform.initialized actually gate?

One latch, moving false to true only, persisted to the external properties file. While it is false: page requests redirect to /setup, other API calls answer 503 JSON rather than a redirect so machine clients get a real error, the setup endpoints are open, Flyway does not migrate at startup, and no scheduled job runs at all — the task is wrapped rather than the future, so periodic jobs re-arm cleanly once the latch flips. The moment it is true, /setup redirects to login and the setup API returns 403.

How do backups work?

The platform console runs pg_dump asynchronously on an operator-editable cron that takes effect without a restart, with a cron field helper in the UI. You can create a backup on demand, list them with created time, status, filename, size, duration and expiry, download one, delete one, and set auto-enable and retention. Backups are installation-wide and superuser-only. Verifying that a backup restores is your responsibility, not the product's.

Can I run Qrly as a GraalVM native image?

Yes — the application is native-image capable, and the schema installation was designed for it. Flyway migrations are resolved by name rather than by classpath scanning, which is exactly the mechanism that breaks under native compilation, so a native image installs the schema identically to a JVM build rather than starting up and finding nothing to run.

Your host, your database, your rows

One application, one PostgreSQL, no phone-home and no external asset requests. Made in Belgium.