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.
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.
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
WebSocket, SSE and NDJSON
- WebSocket push of dashboard card updates
- One Postgres
NOTIFYadapter 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
LISTENcontrol channel accepting reload signals
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
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
- Database. Point Qrly at a PostgreSQL instance and probe the connection. Read-only.
- Schema. Check what is there. Installing is an opt-in action that runs the idempotent migration alone and stamps nothing.
- Tenant. The outermost unit of the multi-tenancy model.
- Organisation. The unit that owns connections, collections and users.
- Admin account. The first superuser. There is no default account with a known password, because there is no seeded account at all.
- Settings. Properties management — read, save, generate a secret, download.
- 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.
- 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.
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.
| Behaviour | While falseUninitialisedBefore the wizard is confirmed | Once trueNormal 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 |
The operator's console
Superuser-only, installation-wide, and built for the two hours where you actually need it rather than for a screenshot.
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.
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.
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.
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.
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
- Alerts and delivery.
AlertSchedulerhourly with per-cadence gating;SubscriptionSchedulerdaily at 06:00. - Cache and materialization.
MaterializationSchedulerper minute for question-level caches;TableMaterializationSchedulerper minute for tier-three crons;TableSyncSchedulerper minute for tier-four configs and groups;QueryCacheEvictionSchedulersweeping the in-process tier every 60 seconds, cleaning the persistent tier hourly and ticking active refresh. - Schema and freshness.
SchemaSyncSchedulernightly;FreshnessSchedulerevery five minutes. - Intelligence.
AnomalySchedulernightly at 03:00;ProactiveInsightSchedulerhourly, honouring a per-organisation cron;ModelerEnrichmentSchedulernightly at 02:30;NarrationWorkerdraining every five seconds withFOR UPDATE SKIP LOCKED;WeeklyInvestigationDigestScheduleron Mondays at 07:00;RiskReportSchedulersweeping for overdue items hourly. - Housekeeping.
BackupSchedulerticking every minute against the operator-editable cron;AuditLogRetentionSchedulerdaily at 03:00;AiCallContextPurgeSchedulerdaily 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.
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
- 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.
- Verified backups. The platform console will run
pg_dumpon 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. - 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.
- 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.
Deployment questions
What do I need to run Qrly?
Does the setup wizard change anything before I confirm?
Why can I not read a secret back out of the setup screen?
${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?
/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?
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.