Builds on the M6a-g MVP (d07e98e) with five user-facing features that
take the in-browser remote-control client from "demo toy" to "workable
for daily use", plus user-facing documentation.
M6h — Text clipboard sync (both directions)
- Host → browser via Clipboard{format: Text} → navigator.clipboard.writeText.
- Browser → host via Cmd-V/Ctrl-V keydown intercept → navigator.clipboard
.readText → Message{clipboard}, sent BEFORE the V keystroke so the
host's paste hotkey lands on the freshly-synced text.
- Handles both single-format Clipboard (older peers) and MultiClipboards
wrapper (peers ≥ 1.3.0; gated by clipboard.rs:is_support_multi_clipboard).
- Switched printable-with-modifier hotkeys (Ctrl-C etc.) from Unicode to
Chr payload — host's process_unicode does key_sequence(char) which
ignores modifiers, breaking copy/cut; process_chr respects them.
- Firefox refuses navigator.clipboard.readText() by default — accepted as
a known browser limitation, host → browser direction works regardless.
M6i — Multi-monitor switching
- HUD picker shown when peer_info.displays > 1.
- On change: SwitchDisplay + CaptureDisplays{set:[idx]} two-message dance
— required for clients ≥ 1.2.4 (we send "1.4.0"). Without the follow-
up, switch_display_to leaves both video services subscribed and
switching display 0 → 1 → 0 doesn't restore display 0.
- Mouse coords offset by the active display's virtual-desktop origin
(DisplayInfo.x, .y). Without this, clicks on display 2 landed on
display 1 because both share canvas (0,0) but only display 1 has
origin (0,0) in virtual-desktop space.
M6j — Quality / FPS / mute controls
- Image quality preset (Low/Balanced/Best) → Misc{option: {image_quality}}.
- Custom FPS (15/30/60) → Misc{option: {custom_fps}}; host caps at 30
unless allow_more_fps is advertised.
- Mute toggle additionally sends Misc{option: {disable_audio: Yes/No}}
so the peer stops encoding audio while muted (saves CPU + bandwidth).
M6k — Auto-reconnect on transient drops
- session.recv() throw → reconnect with exponential backoff: 1s, 2s, 4s,
8s, 16s, 30s, 30s, capped at 30s, max 10 attempts.
- Dim overlay sits on top of the canvas during retry; canvas keeps
last-known frame for visual continuity.
- Auth errors (password/signature) bail immediately — no point retrying.
- User options (mute, image_quality, custom_fps, current display)
re-applied to host on each successful reconnect, since host treats
every session as fresh and resets to defaults.
- Architecture: `session` is a let-binding mutated on reconnect; HUD
button closures read it at click-time so they automatically retarget.
Input modules (mouse/keyboard/clipboard) get a Proxy that forwards
method calls to whatever session is current — avoids re-binding
window/canvas listeners on each reconnect.
M6l — H.264 video decode (Annex-B + SPS-derived codec string)
- decode/bitstream.ts: iterate Annex-B NAL units, derive avc1.PPCCLL
from the keyframe's inline SPS (host's hwcodec defaults to high
profile; a hardcoded baseline string would make WebCodecs refuse the
stream).
- Defer H.264 decoder configure until first keyframe arrives.
- VP9 codec string corrected from level 1.0 (vp09.00.10.08) to level
5.0 (vp09.00.50.08) — wrong level was probably forcing software
decode in some browsers.
- Default prefer flipped to VP8 (cheapest software encoder; H.264 path
stays implemented for hosts with hwcodec/nvenc).
M6m — docs/CONFIGURATION.md "Web client" section: routes, browser
matrix, network requirements (relay reachability + reverse-proxy WS
upgrade), feature status table, codec selection rationale, the
recv/dec/draw HUD diagnostic, build commands.
Bundle: 535 KB / ~75 KB gzipped.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
22 KiB
RustDesk Server (hbbs) — Configuration Guide
This document covers the runtime flags exposed by hbbs, the file formats
it reads (notably oidc.toml), and the operator workflows that string
those together — bootstrap admin, OIDC sign-in, TOTP, address books,
strategies, recordings, the admin dashboard.
The matching desktop-client API surface is documented separately in the
rustdesk repo at docs/CONSOLE_API.md.
CLI flags
Pass flags directly on the command line. There is no config file for
hbbs itself (only the optional oidc.toml referenced from a flag).
Networking & rendezvous
| Flag | Default | Purpose |
|---|---|---|
--port=<NUM> |
21116 |
TCP/UDP rendezvous port. |
--rendezvous-servers=<HOSTS> |
unset | Peer rendezvous servers (comma-separated). |
--relay-servers=<HOSTS> |
unset | Default relay hosts handed to clients. |
--rmem=<BYTES> |
platform default | UDP recv buffer size. Bump along with net.core.rmem_max. |
--mask=<CIDR> |
unset | LAN mask (e.g. 192.168.0.0/16) used to flag local connections. |
--key=<B64> |
derived from id_ed25519 |
Force a specific public key; clients must match. Leave unset to auto-load id_ed25519 next to the binary. |
HTTP API & dashboard
| Flag | Default | Purpose |
|---|---|---|
--http-port=<NUM> |
21114 |
HTTP API port (/api/*) and admin dashboard (/admin/*). 0 disables both. |
--admin-ui-dir=<PATH> |
./admin_ui |
Hint at where the dashboard's static HTML lives. The HTML is embedded in the binary; this flag is informational. Setting it to empty (--admin-ui-dir=) disables the dashboard entirely. |
--public-base-url=<URL> |
unset | The externally-reachable HTTP base of this server, e.g. https://rustdesk.example.com:21114. Required when OIDC is enabled — used to build /oidc/callback redirect URIs. |
Bootstrap admin
| Flag | Purpose |
|---|---|
--bootstrap-admin-username=<USER> |
On first startup, if the users table is empty and both flags are set, insert one admin user. Subsequent restarts ignore these flags (no overwrite). |
--bootstrap-admin-password=<PASS> |
Same. Bcrypt-hashed at insert time. |
If you forget to bootstrap, hbbs logs a warning at startup ("no users in users table"); recover by either restarting with the flags or INSERT INTO users directly via sqlite3.
Address books
| Flag | Default | Purpose |
|---|---|---|
--ab-legacy-mode=<on|off> |
off |
When on, /api/ab/personal returns 404. Forces clients into the legacy single-blob AB mode. |
--ab-max-peers-per-book=<NUM> |
100 |
Surfaced via /api/ab/settings.max_peer_one_ab. Soft cap; the client uses it for UI hints. |
Recordings
| Flag | Default | Purpose |
|---|---|---|
--recording-dir=<PATH> |
./recordings |
Root for /api/record uploads. One subdirectory per peer. |
--recording-max-size-mb=<NUM> |
unset (=unlimited) | Per-file ceiling. Aborts oversized parts. |
Note: Stock OSS RustDesk clients do not upload recordings to
/api/record— the uploader'sENABLEflag atsrc/hbbs_http/record_upload.rshas no setter in OSS source. Server-side recording requires a custom client build that flips that flag. TheRecordingsadmin tab will stay empty for stock clients; the endpoint is provided for wire parity with Pro clients.
Audit retention
| Flag | Default | Purpose |
|---|---|---|
--audit-retention-days=<NUM> |
0 (=keep forever) |
Hourly task deletes audit_conn / audit_file / audit_alarm rows older than N days. |
Email-code login (/api/login with type:"email_code")
| Flag | Default | Purpose |
|---|---|---|
--smtp-host=<HOST> |
unset | If unset, codes are logged to stdout (dev mode) instead of mailed. The email_code login option is also dropped from /api/login-options until SMTP is configured. |
--smtp-port=<NUM> |
587 |
|
--smtp-user=<USER> |
unset | Omit for unauthenticated relays. |
--smtp-pass=<PASS> |
unset | |
--smtp-from=<ADDR> |
noreply@<smtp-host> |
From: header. |
--smtp-tls=<on|off> |
on |
STARTTLS on the SMTP transport. |
OIDC
| Flag | Purpose |
|---|---|
--oidc-config=<PATH> |
TOML file (see below). Providers are upserted into oidc_providers at startup. Re-run with a different file to change providers; rows missing from the new file remain in the DB but can be enabled=0'd via SQL. |
--public-base-url=<URL> |
Required if any provider is configured. Determines the redirect URI registered with the IdP. |
OIDC integration
The server speaks standard OIDC Authorization Code flow with discovery
(/.well-known/openid-configuration). Tested against Zitadel; should work
with any standards-compliant IdP (Keycloak, Auth0, Google, Okta, Authelia,
Dex, etc.).
Two entry points are wired:
- Desktop client —
/api/login-optionsadvertisesoidc/<name>per enabled provider. The Flutter login dialog renders a button per advertised name. Clicking starts the device-flow polling cycle (/api/oidc/auth→ browser →/oidc/callback→/api/oidc/auth-querypoll). - Admin dashboard —
/admin/login.htmlfetches/admin/oidc/providersand renders a "Sign in with X" button per provider. Clicking jumps the browser to/admin/login/oidc/<name>which 302-redirects to the IdP. After the IdP returns to/oidc/callback, the server detects the admin-flow sentinel and finishes by setting the dashboard session cookie + redirecting to/admin/.
oidc.toml schema
Pass via --oidc-config /path/to/oidc.toml.
[[providers]]
# Slug used in URLs (`/admin/login/oidc/<name>`, `/api/login-options`
# advertises `oidc/<name>`). Lowercase, no spaces.
name = "zitadel"
# Display label on the sign-in button.
display_name = "Sign in with Zitadel"
# Optional. Square icon URL shown next to the label (not used yet by all
# UIs; reserved for future button rendering).
# icon_url = "https://example.com/zitadel.svg"
# OIDC issuer. The server fetches `<issuer_url>/.well-known/openid-configuration`
# and caches the discovery doc in-process. Trailing slash is stripped.
issuer_url = "https://idp.example.com"
# Application credentials from the IdP.
client_id = "..."
client_secret = "..."
# Scopes requested at the authorization endpoint. Most setups want
# "openid email profile". Zitadel additionally needs the project audience
# scope to receive role claims (see Role-based admin sync below).
scopes = "openid email profile"
# Optional. If unset, computed as `<--public-base-url>/oidc/callback`.
# Override only when you reverse-proxy under a different host.
# redirect_url = "https://rustdesk.example.com/oidc/callback"
# Optional. Defaults to true.
enabled = true
# --- Role-based admin sync (optional) ---
# When `admin_role` is set, every successful sign-in via this provider
# evaluates the userinfo claim at `roles_claim` and forces the local
# user's `is_admin` to (role present in claim). Promotion AND demotion
# at the IdP propagate. Leave both unset to manage admin status manually
# from the dashboard.
# admin_role = "admin"
# roles_claim = "roles" # or e.g. "urn:zitadel:iam:org:project:roles"
oidc.toml may contain multiple [[providers]] blocks for multi-IdP setups.
Walk-through: Zitadel
In Zitadel
- Project → New project (or pick an existing one).
- New application under the project:
- Type: Web
- Authentication flow: Code (Authorization Code with client secret)
- Auth method: Basic or Post (server sends
client_id+client_secretin the form body — both modes accept that) - Redirect URIs:
<public-base-url>/oidc/callback— character-exact, including scheme. Zitadel rejectshttp://redirects on non-localhost unless dev mode is on, so use TLS in production.
- Authorizations — assign the project's roles to whichever users you want to be admins.
- Project → General: turn on "Assert Roles On Authentication" so roles flow into the userinfo response.
- Copy Client ID and Client Secret from the application's overview page.
oidc.toml
[[providers]]
name = "zitadel"
display_name = "Sign in with Zitadel"
issuer_url = "https://your-instance.zitadel.cloud"
client_id = "PASTE_FROM_ZITADEL"
client_secret = "PASTE_FROM_ZITADEL"
# `urn:zitadel:iam:org:project:id:zitadel:aud` is required for the project's
# roles to be included in the userinfo response.
scopes = "openid email profile urn:zitadel:iam:org:project:id:zitadel:aud"
admin_role = "admin"
roles_claim = "urn:zitadel:iam:org:project:roles"
hbbs flags
./hbbs --http-port 21114 \
--public-base-url 'https://rustdesk.example.com:21114' \
--oidc-config /etc/rustdesk/oidc.toml
Verify
After hbbs starts, look for:
oidc: provider "zitadel" configured
oidc: loaded 1 providers from /etc/rustdesk/oidc.toml
Then:
# 1. Provider visible to the desktop client
curl -s http://127.0.0.1:21114/api/login-options
# expect a list including "oidc/zitadel"
# 2. Provider visible to the admin dashboard
curl -s http://127.0.0.1:21114/admin/oidc/providers
# expect [{"name":"zitadel","display_name":"Sign in with Zitadel",...}]
# 3. Discovery is reachable (IdP-side)
curl -s https://your-instance.zitadel.cloud/.well-known/openid-configuration | jq .issuer
Role-based admin sync
When admin_role is set on a provider, every successful sign-in evaluates
the userinfo claim at roles_claim (defaults to "roles" if unset) and
forces users.is_admin accordingly. Promotion and demotion at the IdP
propagate on the next login.
Two claim shapes are supported:
- Object (Zitadel default at
urn:zitadel:iam:org:project:roles): role names are keys."urn:zitadel:iam:org:project:roles": { "admin": {"123": "myorg"}, "user": {"123": "myorg"} } - Array of strings (generic, common with Keycloak, Auth0 custom claims):
"roles": ["admin", "user"]
Set admin_role = "admin" and either set roles_claim to the exact claim
name (Zitadel) or omit it to default to "roles" (generic).
Sharp edge: when role-sync is configured, manually-granted admin rights in the dashboard get revoked on the next OIDC login if the role isn't present at the IdP. This is the correct contract for a single source of truth, but surprising if you forget. Manage admin status in one place at a time.
Troubleshooting OIDC
- "Sign-in complete" page in browser but desktop client stays at "Waiting account auth": usually a state mismatch between server and client. Check
hbbs.log— the poll endpoint logs every tick at INFO. If you seestatus=successlines that don't stop, suspect a wire-shape mismatch. (This was a real bug we hit and fixed; see git log foroidc envelope.) - Browser shows "identity provider returned an error": check
oidc_sessions.errorfor the row that just failed. Most common:redirect_urimismatch between Zitadel and--public-base-url. - No "Sign in with X" button in the dashboard or desktop client: check
oidc_provider_list_enabled()returns rows. If--public-base-urlis empty,/admin/oidc/providersand/api/login-optionsboth suppress OIDC entries (the redirect URI would be unbuildable). - Admin landing on the "no admin access" error after first OIDC sign-in: expected if
admin_roleisn't configured. Either configure role-sync (preferred), or have the user sign in once to create their row, then promote them on the Users page. The next OIDC sign-in resolves to that row.
TOTP / 2FA
Per-user TOTP is enrolled from the dashboard:
- Sign in as an admin → Users page.
- Pick a user → action menu → Enroll TOTP.
- Scan the QR code into an authenticator (1Password, Authy, Google Authenticator, etc.). The secret is shown once and stored in
user_totp_secrets.
After enrollment, the next desktop-client login flow is:
- Username + password → server returns
{"type":"email_check","tfa_type":"tfa_check","secret":<nonce>}. - Client opens its verification-code dialog → user enters the 6-digit code → re-POSTs
/api/loginwithtype:"email_code"(yes, that's what the desktop client sends for both email and TOTP second legs),tfaCodeset,secretechoed back. - Server verifies the code against
user_totp_secrets, mints an access token, returns{"type":"access_token", ...}.
For dashboard logins, the inline form at /admin/login.html shows the TOTP field after the first password submit returns the prompt fragment.
Strategies (server-pushed config)
Strategies push config_options to peers via heartbeat replies. They are
managed entirely from the dashboard's Strategies page. Resolution
order per peer:
- Direct peer-scoped assignment (
strategy_assignments.peer_id) - Device-group assignment via the peer's owner
- User assignment
The peer's Config::get_option calls reflect the resolved values within
~15 s of any change to modified_at on the strategy row.
Address books
- Personal books are owned per-user and managed from the user's desktop client. The dashboard surfaces them read-only.
- Shared books are server-side artifacts. Create from the dashboard's Address books page → "Manage shares" → grant per-user
read/read+write/fullaccess. Clients pick up shared books on their next AB sync (~30 s).
If you set --ab-legacy-mode=on, /api/ab/personal 404s and clients fall back to the single-blob /api/ab path. Use only if a stock client misbehaves on the modern path.
Admin dashboard URLs
| Path | Auth | What |
|---|---|---|
/admin/, /admin/index.html |
none (login page redirects in JS) | Single-page shell |
/admin/login.html |
none | Sign-in form (password / TOTP / OIDC buttons) |
/admin/login |
none (POST form) | Password+TOTP submit → sets rd_admin_session cookie |
/admin/logout |
cookie | Clears cookie |
/admin/me |
cookie | Sidebar's logged-in-as widget |
/admin/oidc/providers |
none | JSON list of enabled providers, used by login.html |
/admin/login/oidc/:name |
none | Starts admin OIDC flow (302s to IdP) |
/admin/pages/users |
cookie + admin | Users page fragment |
/admin/pages/devices |
cookie + admin | Devices (incl. delete) |
/admin/pages/groups |
cookie + admin | Device groups |
/admin/pages/strategies |
cookie + admin | Strategy management |
/admin/pages/address-books |
cookie + admin | Personal + shared books |
/admin/pages/oidc |
cookie + admin | Read-only OIDC provider listing |
/admin/pages/audit |
cookie + admin | Audit log browser |
/admin/pages/recordings |
cookie + admin | Recording file listing |
/admin/pages/deploy |
cookie + admin | --config blob + renamed-installer generator |
The session cookie (rd_admin_session) is HttpOnly + SameSite=Strict.
The middleware accepts the same cookie or Authorization: Bearer …,
so the same auth covers /api/* for the desktop client and /admin/*
for the dashboard with no separate session model.
Web client
Admins can remote-control any peer directly from the browser — no desktop
RustDesk install required. The Devices page row dropdown surfaces a
Connect button that opens /admin/connect/<peer_id> in a new tab.
The page is a TypeScript SPA bundled into hbbs (include_bytes! from
web_client/dist/), so there's no separate process or service to run.
Routes
| Route | Auth | Purpose |
|---|---|---|
/admin/connect/:peer_id |
cookie + admin | Server-rendered HTML wrapper that injects per-request config (rendezvous host, relay host, server pk, peer id, admin display name) into a <script id="custom-config"> tag, then loads bundle.js. |
/admin/connect/assets/bundle.js |
cookie + admin | The compiled SPA. ~535 KB, ~75 KB gzipped. |
/admin/connect/assets/bundle.css |
cookie + admin | Minimal dark theme + reconnect overlay. |
How it talks to peers
The browser opens WebSockets directly to the existing rendezvous
(hbbs:21118, the ws_port) and relay (hbbr:21119, port + 2)
sockets. The wire shape — protobuf RendezvousMessage + secretbox-
encrypted Message stream — is identical to what the desktop client
uses; the SPA is just a from-scratch reimplementation of the protocol
talking through WS frames instead of TCP.
Browsers can't do UDP NAT-punching, so the SPA always sets force_relay = true in PunchHoleRequest and follows the RelayResponse branch
unconditionally. Direct peer-to-peer is never attempted.
Browser requirements
- Secure context — page must be served over HTTPS or
http://localhost. WebCodecsVideoDecoder/AudioDecoderare gated to secure contexts. Plainhttp://lan-ip:21114will fail; either use TLS in front of hbbs or dossh -L 21114:localhost:21114 hbbs-hostand connect viahttp://localhost:21114. - Browser version — Chrome 94+, Edge 94+, Firefox 130+, Safari 16.4+. Older browsers lack WebCodecs and will throw "VideoDecoder unavailable" on session start.
- Clipboard permission — Firefox refuses
navigator.clipboard.readText()by default, so admin → host paste is silently no-op there. Host → admin paste works (it useswriteText, which only needs a recent click). Chrome/Safari prompt once.
Network requirements
- The relay host advertised to clients (
--relay-servers=<HOSTS>on hbbs) must resolve and be reachable from the end-user's browser on port 21119. The relay is what carries the actual session bytes — if a user's browser can't openws://<relay-host>:21119/, the session dies after the rendezvous step. A common gotcha: setting--relay-servers=hbbr-internal.localworks for desktop clients on the LAN but breaks for browsers off-LAN. - Reverse proxies must forward the WebSocket upgrade for both 21118 (rendezvous) and 21119 (relay). Caddy:
reverse_proxy /ws/* hbbs:21118plus equivalent for 21119; nginx: the standardproxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade";block. - Audit rows are written under the admin's cookie via the existing
/api/audit/connendpoint; no new server endpoint.
Features
| Feature | Status |
|---|---|
| Video (VP8 / VP9 / H.264) | ✅ — preference is VP8 (lightest software encoder; H.264 path also implemented for hosts with hwcodec) |
| Audio (Opus) | ✅ |
| Mouse + keyboard input | ✅ — Legacy keyboard mode; Translate mode silently drops Unicode/ControlKey payloads on the host |
| Text clipboard sync (both directions) | ✅ — handles both single-format Clipboard and MultiClipboards (peers ≥ 1.3.0) |
| Multi-monitor switching | ✅ — SwitchDisplay + CaptureDisplays two-message dance for hosts ≥ 1.2.4; mouse coords offset by display's virtual-desktop origin |
| Image quality presets (Low/Balanced/Best) | ✅ |
| Custom FPS (15/30/60) | ✅ — host caps at 30 unless allow_more_fps is advertised |
| Mute toggle | ✅ — also tells host to stop encoding audio (saves CPU + relay bandwidth) |
| Ctrl+Alt+Del | ✅ — Windows hosts only (server-side #[cfg(windows)]) |
| Auto-reconnect on transient drops | ✅ — up to 10 attempts, exponential backoff 1s → 30s, dim overlay during retry, user options re-applied on success |
| File transfer | ❌ deferred — separate FileAction family, double the surface area |
| AV1 / H.265 decode | ❌ deferred — VP8/VP9/H.264 covers the common cases |
| IME / compose input | ❌ deferred — needs compositionend + KeyEvent.seq |
| Touch gestures | ❌ deferred |
| Cursor sprite rendering | ❌ deferred — host-side cursor visible in the video; we don't draw a separate one |
Codec selection
The SPA advertises VP8/VP9/H.264 decode and prefers VP8. The host's
codec picker (libs/scrap/src/common/codec.rs) honours the preference
when the host has the matching encoder available, else falls back to
its "auto" path (H.265 → H.264 → AV1/VP9/VP8).
VP8 is the default because it's the cheapest software encoder; on a
host without hwcodec, VP9 software-encode caps screen sharing at single
digits FPS, while VP8 keeps headroom for the screen-capture pipeline.
On a host with hwcodec H.264 (nvenc/qsv on most Windows boxes), flip
the preference in web_client/src/transport/session.ts to
PreferCodec.H264 — the SPA's H.264 path parses SPS to derive the
correct avc1.PPCCLL codec string for WebCodecs.
Performance gotchas
The HUD shows three live numbers: recv (frames/sec arriving from the
relay), dec (frames/sec the browser decoded), draw (frames/sec
painted to canvas). Use them to localise FPS issues:
- All three low → host is encoding slowly. Either CPU-bound (no
hwcodec, VP9 chosen) or QoS-throttled by host based on
TestDelayRTT measurements. recvhigh,declow → browser fell back to software decode. Check the codec string at the end of the HUD line; mismatched profile/level (e.g.avc1.42E01Efor a high-profile stream) forces software.dechigh,drawlow → main thread is overwhelmed (very rare with hardware decode).
Building the bundle
The bundle is committed under web_client/dist/ so a Cargo build
doesn't need a Node toolchain. To regenerate after editing the SPA:
cd web_client
npm install # one-time
npm run build # → dist/bundle.{js,css}
git add dist/
cd .. && cargo build --release -p hbbs
To regenerate protobuf bindings after a libs/hbb_common/protos/*.proto
change:
cd web_client && npm run protogen
Database
SQLite, file db_v2.sqlite3 in hbbs's working directory. Tables created
at startup with CREATE TABLE IF NOT EXISTS; column additions use
ALTER TABLE ADD COLUMN guarded by a duplicate-column-name swallower
(SQLite < 3.35 lacks ADD COLUMN IF NOT EXISTS).
Backup is a plain file copy while hbbs is stopped, or sqlite3 db_v2.sqlite3 .dump > backup.sql while running. There is no
multi-instance HA; run a single hbbs against a single SQLite file.
Security checklist before exposing to the internet
- TLS in front of
--http-port(Caddy / nginx / Traefik). Required for OIDC redirect URIs in production. --public-base-urlset to the externally reachable URL, including the scheme.--bootstrap-admin-passwordrotated immediately after first login (Users page → reset password).--key/id_ed25519not committed to source control. Treat the private key as a deploy secret.- Audit retention (
--audit-retention-days) set to a value that matches your data-retention policy. - If running behind a reverse proxy: forward the original
Host:header so OIDC redirect-URI validation matches.