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.
One application, one database, and an optional coordination service if you run more than one node. That is the whole dependency list.
001.sql…NOTIFY adapter per live dashboardLISTEN control channel accepting reload signalsThe 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.
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.
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.
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.
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.
/setup/setup redirects to login503 JSON — not a redirect403/live and /ready open/metrics in Prometheus textSuperuser-only, installation-wide, and built for the two hours where you actually need it rather than for a screenshot.
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 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 runtimeCreate 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.
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.
Every recurring job in the product, named — because an operator who cannot name the background work cannot reason about the load profile.
AlertScheduler hourly with per-cadence gating; SubscriptionScheduler daily at 06:00.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.SchemaSyncScheduler nightly; FreshnessScheduler every five minutes.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.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.
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.
Self-hosting means the operational boundary sits somewhere, and it is fairer to draw it than to let you discover it.
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.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.
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.
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.
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.
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.
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.
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.
One application, one PostgreSQL, no phone-home and no external asset requests. Made in Belgium.