Date: 2026-07-19
Accepted
Today islandr serves plain HTTP. Every deployment guide points operators at a reverse proxy (Caddy, Traefik, nginx) or an edge/CDN provider to terminate TLS — see README's tech-stack table (TLS | Caddy or Let's Encrypt at the edge). For islandr's target audience (PRD §3 — Felix the sysadmin running a 30–80 person company, and the homelab operator), that proxy is the last mandatory external moving part in an otherwise self-contained deployment (systemd unit or Docker image + optional socket proxy, ADR-0011/0012). Many of these operators already sit behind a CDN/DDoS provider (Cloudflare, CloudFront, Fastly, Azure Front Door, Akamai) that hands out an edge/origin certificate for free — for them, running a second TLS-terminating process purely to hold that certificate is pure ceremony.
Issue #22 asks: can islandr terminate HTTPS itself, with a certificate importable from any provider (not just Cloudflare), swappable at runtime without a restart?
Cloudflare is the worked example in the issue, not the assumption. The design below treats "import a cert + key" and "reference a file path" as the only two primitives — nothing is Cloudflare-specific in code, config, or copy. AWS CloudFront + ACM, Fastly, Azure Front Door, Akamai, Google Cloud CDN/Armor, or a Let's Encrypt cert obtained by any external tool all flow through the same two import modes.
Quarkus's modern TLS layer (quarkus.tls.*, the "TLS registry") is built for exactly this: a TlsConfiguration can be reloaded at runtime — reload() refreshes the keystore/truststore, and firing a io.quarkus.tls.CertificateUpdatedEvent tells the running Vert.x HTTP server to call updateSSLOptions() with the new material. No restart, no dropped connections. This is materially different from (and replaces) the older quarkus.http.ssl.certificate.* properties, several of which are build-time-fixed and native-image-baked — unusable for this feature.
Quarkus's built-in reload mechanism only watches the filesystem — it re-reads a keystore file on a poll interval. That's the right fit for the "reference a file path" import mode (operator's own ACME client or CDN tooling manages the file; islandr never touches or copies it — same trust boundary Caddy/nginx already have today). It is the wrong fit for the "islandr holds the material" import mode, where forcing a filesystem round-trip would mean writing the private key to disk on every certificate change — a needless increase in the file-based attack surface for a value that only needs to exist in memory once decrypted.
For that mode, Quarkus exposes io.quarkus.tls.KeyStoreProvider — a CDI bean Quarkus calls whenever a named TlsConfiguration is created or reloaded. An islandr-authored KeyStoreProvider builds a java.security.KeyStore in memory from the current DB row (decrypting the private key only for the duration of that call) and hands it back; nothing is ever written to disk. Saving new certificate material in Settings triggers the same reload() + CertificateUpdatedEvent pair used by the file-reference mode — from the outside, both modes look identical (instant, no restart); only the source of the bytes differs.
This mirrors a pattern islandr already relies on elsewhere: the database is the source of truth and the single thing an admin edits (ADR-0008), and a reload primitive — not a restart — is how a change takes effect (the nftables ruleset is recomputed and atomically reloaded via nft -f, ADR-0003/0012, on every ACL change). TLS certificate material gets the same shape of answer, adapted to where the material physically needs to live.
islandr already has a working answer for "a secret the app itself needs to keep functioning across restarts, not a client artifact it can choose to forget": EncryptionService (AES-256-GCM, enc$-prefixed, key delivered via systemd-creds or an env var, ADR-0007) already encrypts the Google Workspace service-account JSON unconditionally when a key is configured (SettingsService.updateGoogleWorkspace). The TLS private key is the same category of secret — islandr needs it present and decryptable to keep serving HTTPS, so ADR-0007's peer-key model (never / plaintext / encrypted, with a real "never persist" option) doesn't fit: there is no meaningful "never" mode for a certificate islandr itself terminates TLS with. The Google-Workspace-JSON precedent is the correct fit and is reused as-is: encrypted at rest when EncryptionService.isConfigured(), plaintext with an explicit admin-facing warning banner otherwise (identical banner language pattern to ADR-0007's plaintext retention mode).
The issue explicitly sequences this as a second step ("needs port 80 briefly reachable from the internet"). Exposing port 80, running an ACME client, and scheduling renewal is materially more code and a materially different threat model (unauthenticated inbound HTTP validation traffic) than importing material an operator already has. Bundling it into this ADR would conflate two decisions with different risk profiles. This ADR's scope is manual import (either storage mode below); ACME is deferred to a follow-up issue once this ships and the reload mechanism is proven in production.
Built-in HTTPS via Quarkus's TLS registry, with two operator-chosen certificate storage modes — mirroring the DB-first-with-escape-hatch shape islandr already uses for private-key retention (ADR-0007) and runtime settings (ADR-0008):
Managed (DB-stored). Admin uploads a .p12 or a PEM cert + private key via Settings. The certificate and (encrypted-when-configured) private key are stored in the settings table. An islandr-authored KeyStoreProvider CDI bean builds the KeyStore in memory on every TlsConfiguration creation/reload — the private key is decrypted only transiently, for the duration of that call, and is never written to disk. Saving new material calls TlsConfiguration.reload() and fires CertificateUpdatedEvent immediately — no restart, no wait for a poll interval.
Referenced (file path). Admin points Settings at a filesystem path islandr does not manage (e.g. mounted by the operator's own ACME client or a CDN's origin-cert delivery tooling — a Docker secret, a bind mount). islandr configures a named quarkus.tls.<name>.key-store.path pointing at it and relies entirely on Quarkus's own reload-period file-watch — no custom code, no material ever touches the database. Same trust boundary reverse proxies already have today: the operator's own filesystem permissions are the security boundary.
Both modes replace the same server-facing named TLS configuration, so the running HTTP endpoint doesn't know or care which mode is active — switching between them is itself just another reload.
Not in this ADR's scope: ACME/Let's Encrypt auto-provisioning (tracked as a follow-up issue once this ships).
Baseline: Built-in TLS via Quarkus TLS registry, dual storage mode (the decision). +1 better, 0 equal, −1 worse.
| Criterion (weight) | Built-in TLS (baseline) | Reverse proxy required (status quo) | Tunnel sidecar (e.g. Cloudflare Tunnel) | Built-in ACME | Classic quarkus.http.ssl.* (build-time-fixed) |
|---|---|---|---|---|---|
| Zero mandatory external process for TLS (6) | 0 | −1 | −1 | +1 | 0 |
| No restart needed to rotate a certificate (5) | 0 | +1 (proxy already solves this) | +1 | 0 | −1 (the exact problem this ADR solves) |
| Provider-neutral (any CDN/CA cert imports the same way) (4) | 0 | +1 | −1 (tunnel products aren't interchangeable across providers) | −1 (ACME is CA-specific, and CDN-fronted deployments often can't expose port 80) | 0 |
| Private-key exposure added to islandr's own process/DB (4) | 0 | +1 (islandr never touches it) | +1 | 0 | 0 |
| Native-image compatibility / build complexity (3) | 0 | +1 (no islandr TLS code at all) | +1 | ? (unverified ACME-client native compat) | +1 (simpler mechanism, but disqualified above) |
| Implementation + new attack-surface cost (3) | 0 | +1 | +1 | −1 | +1 |
| Weighted total | 0 | 12 | 9 | −2 | −2 |
Notes:
?) for whichever ACME client library would be used — consistent with the issue's own sequencing (second step, not this ADR).quarkus.http.ssl.* is disqualified outright on the one criterion that matters most (no-restart), which is the literal problem statement; it is included only to show the newer TLS registry is a strict improvement, not a style preference.settings, following the existing runtime-settings pattern (ADR-0008).EncryptionService gains a second consumer beyond peer private keys and the Google Workspace service-account JSON; no change to the service itself.KeyStoreProvider CDI bean is islandr-authored code at a security boundary (parses attacker-controllable — admin-supplied — PKCS12/PEM material). It must reject malformed or mismatched cert/key pairs with a clear error before ever calling reload(), so a bad upload can't take the HTTPS endpoint down.KeyStoreProvider call, exists in JVM heap for the duration of that call. Mitigation: encrypted at rest when EncryptionService is configured (same guarantee as ADR-0007 encrypted mode); the decrypted value is not cached or retained beyond building the KeyStore object.reload(); keep serving the previous working configuration on validation failure.EncryptionService, reused here for the managed mode's private keyreload(), CertificateUpdatedEvent, KeyStoreProvider