Date: 2026-05-30
Accepted
While wiring up the first peer-create endpoint, six configuration values landed in application.properties:
islandr.wg.subnet=10.8.0.0/24
islandr.wg.server.publicKey=PLACEHOLDER...
islandr.wg.server.endpoint=vpn.example.com:51820
islandr.wg.client.allowedIps=10.8.0.0/24
islandr.wg.client.dns=
islandr.peer.privateKey.retention=never
All six are values the admin needs to change through the UI — the WG server public key changes when the hub is rekeyed, AllowedIPs grows when new sites are added, retention mode is a runtime policy decision. Treating them as Quarkus config properties means:
The same problem will compound as more knobs appear: nftables ruleset reload throttle, activity-poll interval, peer-disable-on-stale window, email/Slack notification settings.
Two camps for handling this:
application.properties / env vars, no UI editing, "infrastructure-as-code" mindset. Operators edit the file, restart the service.settings table with a UI editor.Camp A is what Quarkus-default tutorials show. It's right for deployment-shape config: which port, which database. It's wrong for operational-state config: what's the VPN endpoint URL, what's the retention policy. The product we're building is a self-hosted admin tool; the admin lives in the UI, not in the file system.
Split config into two homes:
Lives in application.properties / env |
Lives in DB settings table (admin-editable via UI) |
|---|---|
quarkus.datasource.* (DB connection) |
wg.subnet |
quarkus.http.port |
wg.server.publicKey |
islandr.wg.mode (mock / real) |
wg.server.endpoint |
islandr.wg.interface (wg0) |
wg.client.allowedIps |
| Flyway / Hibernate plumbing | wg.client.dns |
peer.privateKey.retention (never / plaintext) |
Rule of thumb: if changing the value would require a service restart anyway (DB connection), it stays in properties. If it's a policy or topology decision the admin should be able to flip from the UI, it goes in the DB.
Schema: a singleton settings table.
CREATE TABLE settings (
id INTEGER NOT NULL PRIMARY KEY CHECK (id = 1),
wg_subnet VARCHAR(50) NOT NULL,
wg_server_public_key VARCHAR(44) NOT NULL,
wg_server_endpoint VARCHAR(255) NOT NULL,
wg_client_allowed_ips TEXT NOT NULL,
wg_client_dns VARCHAR(255) NULL,
private_key_retention VARCHAR(20) NOT NULL DEFAULT 'never',
updated_at TIMESTAMP NOT NULL,
updated_by VARCHAR(255) NOT NULL
);
The CHECK (id = 1) enforces exactly one row. INSERT ON CONFLICT (id) DO UPDATE (or the equivalent Hibernate save) is the only write path. No "which row is current?" question.
Flyway seeds defaults. First-run migration inserts the single row with placeholders (PLACEHOLDER_SERVER_PUBKEY...) so the app boots cleanly. The Admin Console shows a permanent "Setup unvollständig" banner until the placeholders are replaced.
Access pattern. A SettingsService (@ApplicationScoped) reads the row on demand. No caching in v1 — SQLite hits are sub-millisecond and the read frequency is low (peer create, peer reshow, nftables recompute). Add a cache invalidated on PUT /settings if profiling shows a hot path.
Baseline: Singleton settings table + bootstrap properties (the decision).
| Criterion (weight) | Singleton table (baseline) | All in application.properties | EAV (key/value table) | Per-domain tables (wg_settings, peer_settings) |
|---|---|---|---|---|
| Admin can change without restart (5) | 0 | -1 | 0 | 0 |
| Type safety / schema validation (4) | 0 | 0 | -1 | 0 |
| Audit trail of changes (4) | 0 | -1 | 0 | 0 |
| UI editor complexity (3) | 0 | -1 | -1 | -1 |
| Race-condition resistance (CHECK id=1) (3) | 0 | 0 | -1 | 0 |
| Migration cost when adding a new setting (2) | 0 | +1 | +1 | 0 |
| Lines of code (2) | 0 | +1 | 0 | -1 |
| Weighted total | 0 | −6 | −5 | −3 |
Honest reading:
application.properties scores well on "lines of code" and "easy to add" because there's no schema. Loses hard on the criteria that motivated this ADR. Reject.settings(key, value)) trades a VARCHAR column for schema clarity. Every read becomes WHERE key = '…', every write a VARCHAR interpretation. Loses on type safety (everything's a string), on race-resistance (no PK constraint on the set of settings), and on UI complexity (the editor has to know per-key types out-of-band). Common antipattern. Reject.wg_settings, peer_settings, etc.) sounds tidy but creates the "who owns the saving" question — PUT /settings becomes N queries in a transaction, the UI has to reason about multiple endpoints. Loses on UI complexity and LOC for a v1-sized config. Defer until we have ≥20 settings spread across truly independent domains.ALTER TABLE settings ADD COLUMN and forces a moment of "should this really be in settings or properties." That friction is a feature.Positive
who, when, what changed). No SSH.wg_server_public_key = "totally not a base64 key" if the column has a length constraint and the service validates.ALTER TABLE settings ADD COLUMN foo + new entity field + new DTO field + new UI input.Risks created
SettingsService.update() method runs side-effects per changed field — for retention switch this means warning the admin in the UI before the change, and offering a purge-private-keys action after.PUT /settings if profiling proves it's needed.wg.mode (mock/real) deliberately stays in properties even though "could be runtime-toggled" was discussed. Mitigation: switching from mock to real at runtime would silently change every peer's reachability — we don't want that to be possible through a UI checkbox. Restart-required is the right friction here.Accepted trade-offs
/api/v1/settings from their deploy script.GET /api/v1/settings and PUT /api/v1/settingsprivate_key_retention setting is moved from properties to DB by this ADR