Deployment & Operations ← Capability map

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.

The Stack

What you are actually running

One application, one database, and an optional coordination service if you run more than one node. That is the whole dependency list.

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
First Run

Eight steps, on a shell that reads no application database

The setup wizard runs on its own standalone shell with no application-database reads, because at that point there may be no schema to read. There is no seeded account.

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.

One Latch

What platform.initialized gates

A single flag, moving false → true only, persisted to the external properties file. Everything about an uninitialised installation follows from it, which is why there is exactly one of it.

Legend Behaviour Deliberately inert
Behaviour While falseUninitialisedBefore the wizard is confirmed Once trueNormal operation
Page requestsRedirect to /setup/setup redirects to login
Other API calls503 JSON — not a redirectNormal responses; machine clients get a real error either way
Setup endpointsOpenThe setup API returns 403
Flyway at startupDoes not migrateMigrates normally, resolved by name
Scheduled jobsNone run at allAll fifteen run; the task is wrapped, not the future, so periodic jobs re-arm
Health endpoints/live and /ready openStill open — plus /metrics in Prometheus text
Direction of travelfalse → true onlyPersisted to the external properties file; it does not go back
Platform Console

The operator's console

Superuser-only, installation-wide, and built for the two hours where you actually need it rather than for a screenshot.

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.

Background Work

Seventeen schedulers, all gated until initialisation

Every recurring job in the product, named — because an operator who cannot name the background work cannot reason about the load profile.

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.

Be Clear About This

What you are responsible for

Self-hosting means the operational boundary sits somewhere, and it is fairer to draw it than to let you discover 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.

FAQ

Deployment questions

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.
Keep Reading

Your host, your database, your rows

One application, one PostgreSQL, no phone-home and no external asset requests. Made in Belgium. See Qrly Pricing Talk to Us
Machine translation

Read this site in your language.

The site is written in English. Google Translate can show it in Dutch, French and Luxembourgish. The translation is automatic and not reviewed by us: for prices and terms the English page is the one that counts.

Translation by Google. Choosing a language loads Google Translate, sends the text of the pages you open to Google and stores one cookie in this browser until you close it. English switches all of that off. Google privacy policy