A self-hosted web app for managing Linux package updates across multiple servers. Connect over SSH, check for updates, and apply maintenance from one browser dashboard.
- Multi-distribution updates: APT, DNF, YUM, Pacman, APK, Flatpak, Snap, and custom package managers.
- SSH credential vault: reusable password, key, and OpenSSH certificate credentials encrypted at rest with AES-256-GCM.
- Automatic discovery: package managers, OS metadata, installed package inventory, system info, reboot state, and distribution lifecycle status.
- Granular maintenance: refresh, upgrade all, grouped Upgrade All batches, selected-package upgrades, full upgrades, autoremove, cancellation, and remote reboot.
- Per-system controls: hidden systems, system duplication, default Upgrade All exclusion, package-manager toggles/config, APT kept-back auto-hide, script overrides, ProxyJump, and host-key trust approval.
- Script customization: inspect built-in SSH command scripts, copy them into editable custom scripts, define parser settings, import/export custom package managers, and assign overrides per system.
- Scheduling: cron-based refresh, update, and notification schedules with scoped systems, cache rules, ordered upgrade groups, and schedule run history.
- Notifications: Email/SMTP, Gotify, MQTT, ntfy.sh, Telegram, and Webhook channels with event filters, system scope, immediate or scheduled delivery, test sends, and encrypted secrets.
- Home Assistant MQTT: app and per-system update entities with discovery, retained state/attributes, rich metadata, images, and optional install commands.
- Authentication: password login, passkeys/WebAuthn, OpenID Connect SSO, and API tokens for external integrations.
- Localized UI: Arabic, English, German, French, Hindi, Japanese, Portuguese, Spanish, Russian, and Chinese with browser-language detection.
- Operator-friendly UI: dark/light mode, activity history, real-time command output, dashboard status summaries, version/build info, and Docker-ready production packaging.
Caution
Use on trusted networks only. Do not expose Linux Update Dashboard directly to the internet. For remote access, put it behind TLS, a hardened reverse proxy, authentication, and network-level controls such as VPN or firewall rules.
Important
HTTPS is recommended. Plain HTTP works for basic local dashboard use, but browsers restrict secure-context features such as passkeys/WebAuthn and clipboard actions. Behind a reverse proxy, set LUDASH_BASE_URL to the public https://... URL and enable LUDASH_TRUST_PROXY=true.
|
|
CrowdSec Web UI A self-hosted web dashboard for CrowdSec to review alerts, manage decisions, configure notifications, and optionally view runtime metrics. |
Prerequisites:
- Node.js 24.18.0
- pnpm 11.9.0 through Corepack or global install
- SSH access to at least one Linux server
git clone https://github.com/TheDuffman85/linux-update-dashboard.git
cd linux-update-dashboard
corepack enable
corepack prepare pnpm@11.9.0 --activate
pnpm install
export LUDASH_ENCRYPTION_KEY=$(openssl rand -base64 32)
pnpm run devThe Vite frontend runs on http://localhost:5173 and proxies API calls to the backend on port 3001. The first visit guides you through creating the admin account.
Production from source:
pnpm run build
NODE_ENV=production pnpm run startThe production server serves the API and built frontend on port 3001.
Generate an encryption key once and persist it. Changing LUDASH_ENCRYPTION_KEY later makes existing encrypted credentials unreadable.
export LUDASH_ENCRYPTION_KEY=$(openssl rand -base64 32)
export LUDASH_BASE_URL=http://localhost:3001
docker run -d \
--name linux-update-dashboard \
-p 3001:3001 \
-e LUDASH_ENCRYPTION_KEY=$LUDASH_ENCRYPTION_KEY \
-e LUDASH_BASE_URL=$LUDASH_BASE_URL \
-v ludash_data:/data \
ghcr.io/theduffman85/linux-update-dashboard:latestSet LUDASH_BASE_URL to the URL users and integrations actually use. Behind a reverse proxy, set the public HTTPS URL and add LUDASH_TRUST_PROXY=true.
services:
dashboard:
image: ghcr.io/theduffman85/linux-update-dashboard:latest
container_name: linux-update-dashboard
restart: unless-stopped
ports:
- "3001:3001"
volumes:
- dashboard_data:/data
environment:
- LUDASH_ENCRYPTION_KEY=${LUDASH_ENCRYPTION_KEY}
- LUDASH_DB_PATH=/data/dashboard.db
- LUDASH_BASE_URL=http://localhost:3001
- TZ=Europe/Berlin
- NODE_ENV=production
# Reverse proxy:
# - LUDASH_BASE_URL=https://dashboard.example.com
# - LUDASH_TRUST_PROXY=true
volumes:
dashboard_data:Docker Secrets are supported by setting LUDASH_ENCRYPTION_KEY_FILE instead of LUDASH_ENCRYPTION_KEY:
services:
dashboard:
image: ghcr.io/theduffman85/linux-update-dashboard:latest
container_name: linux-update-dashboard
restart: unless-stopped
ports:
- "3001:3001"
volumes:
- dashboard_data:/data
environment:
- LUDASH_ENCRYPTION_KEY_FILE=/run/secrets/ludash_encryption_key
- LUDASH_DB_PATH=/data/dashboard.db
- LUDASH_BASE_URL=http://localhost:3001
- NODE_ENV=production
secrets:
- ludash_encryption_key
secrets:
ludash_encryption_key:
file: ./secrets/ludash_encryption_key.txt
volumes:
dashboard_data:Create the secret before starting:
mkdir -p ./secrets
openssl rand -base64 32 > ./secrets/ludash_encryption_key.txt
docker compose up -dFor plain docker run, mount the secret file read-only and set LUDASH_ENCRYPTION_KEY_FILE=/run/secrets/ludash_encryption_key.
The repository also includes docker/docker-compose.yml for local image builds:
cd docker
export LUDASH_ENCRYPTION_KEY=$(openssl rand -base64 32)
export LUDASH_BASE_URL=http://localhost:3001
export TZ=Europe/Berlin
docker compose up -dThe image includes a health check for GET /api/health; loopback requests do not require auth, external requests do. The app returns {"status":"ok"} when healthy.
| Variable | Required | Default | Description |
|---|---|---|---|
LUDASH_ENCRYPTION_KEY |
Yes | - | Secret used for stored credential and notification-secret encryption. Raw 32-byte base64 keys are used directly; other values are derived with PBKDF2-SHA256. |
LUDASH_ENCRYPTION_KEY_FILE |
No | - | Docker Secrets/file alternative for LUDASH_ENCRYPTION_KEY. Do not set both. |
LUDASH_DB_PATH |
No | ./data/dashboard.db |
SQLite database path. Docker images default this to /data/dashboard.db. |
LUDASH_SECRET_KEY |
No | auto-generated | JWT session signing secret, persisted to .secret_key next to the database when omitted. |
LUDASH_SECRET_KEY_FILE |
No | auto-generated | File alternative for LUDASH_SECRET_KEY. Do not set both. |
LUDASH_PORT |
No | 3001 |
HTTP server port. |
LUDASH_HOST |
No | 0.0.0.0 |
HTTP bind address. |
LUDASH_BASE_URL |
Recommended | http://localhost:3001 |
Public URL used for WebAuthn/OIDC and integration URLs such as Home Assistant entity_picture/origin.url. |
LUDASH_TRUST_PROXY |
No | false |
Trust X-Forwarded-* headers behind a reverse proxy. |
TZ |
No | UTC |
IANA timezone for the container/process, UI time display, and cron schedules. |
LUDASH_LOG_LEVEL |
No | info |
debug, info, warn, or error. Debug adds SSH diagnostics and scheduler refresh logs. |
LUDASH_DEFAULT_CACHE_HOURS |
No | 12 |
Startup default for cache reuse hours. User settings can override runtime behavior. |
LUDASH_DEFAULT_SSH_TIMEOUT |
No | 30 |
Startup default SSH connect timeout in seconds. |
LUDASH_DEFAULT_CMD_TIMEOUT |
No | 120 |
Startup default SSH command timeout in seconds. |
LUDASH_MAX_SSH_TIMEOUT |
No | 120 |
Maximum SSH timeout accepted by settings/API. |
LUDASH_MAX_CMD_TIMEOUT |
No | 600 |
Maximum command timeout accepted by settings/API. |
LUDASH_MAX_CONCURRENT_CONNECTIONS |
No | 5 |
Startup default for simultaneous SSH connections. User settings can override runtime behavior. |
LUDASH_MIN_SCHEDULE_INTERVAL_MINUTES |
No | 5 |
Minimum interval accepted for cron schedules. |
VITE_MIN_SCHEDULE_INTERVAL_MINUTES |
No | 5 |
Build-time client hint for schedule interval warnings; set it to match LUDASH_MIN_SCHEDULE_INTERVAL_MINUTES when customizing. |
LUDASH_EOL_CATALOG_FILE |
No | - | Custom distribution lifecycle catalog used by pnpm run generate:eol and pnpm run build. |
NODE_EXTRA_CA_CERTS |
No | - | Additional PEM CA bundle for outbound TLS targets such as OIDC, SMTP, Gotify, ntfy, or webhooks. |
NODE_ENV |
No | - | Set to production to serve the built SPA from the backend. |
For secret-backed settings, do not set both VAR and VAR_FILE; startup fails with a configuration error when both are present for the same setting.
Linux Update Dashboard supports password login, passkeys, OpenID Connect SSO, and API tokens. Passwords use bcrypt, sessions are HTTP-only JWT cookies with rolling refresh, and password login cannot be disabled unless passkeys or SSO are configured to prevent lockout.
For OIDC, configure the provider callback URL as {LUDASH_BASE_URL}/api/auth/oidc/callback. Set LUDASH_BASE_URL before configuring OIDC so callback and origin validation match the public URL.
API tokens are for external integrations such as gethomepage widgets, scripts, monitoring, and Telegram command automation. Tokens can be read-only or read/write, expire after 30/60/90/365 days or never, are stored as SHA-256 hashes, are limited to 25 per user, and cannot access management endpoints or configure SSH connections.
curl -H "Authorization: Bearer ludash_..." http://localhost:3001/api/dashboard/statsThe endpoint catalog lives in API.md.
| Package Manager | Distributions |
|---|---|
| APT | Debian, Ubuntu, Linux Mint |
| DNF | Fedora, RHEL 8+, AlmaLinux, Rocky |
| YUM | CentOS, older RHEL |
| Pacman | Arch Linux, Manjaro |
| APK | Alpine Linux |
| Flatpak | Any cross-distribution host |
| Snap | Any cross-distribution host |
| Custom | User-defined scripts and parser rules |
Managers are detected over SSH when testing a connection or running the first check. Detected managers are enabled by default and can be toggled per system. Security updates are identified where the package manager exposes enough information.
Per-system manager settings include APT upgrade vs full-upgrade and kept-back auto-hide, DNF upgrade vs distro-sync, optional metadata refresh skips for DNF/Pacman/APK/Flatpak, and opt-in DNF/YUM automation for GPG-key and EULA prompts. Snap does not currently expose manager-specific settings.
Schedules are managed from the Schedules page. Existing installs migrate to an enabled Default refresh schedule using the previous refresh interval and cache settings.
- Refresh schedules re-check scoped systems when cached results are stale.
- Update schedules refresh scoped systems first, then run the normal per-system Upgrade action where visible updates remain.
- Notification schedules deliver pending event batches for assigned notification channels.
Schedules use five-field cron expressions in the process timezone. Set Docker TZ, such as TZ=Europe/Berlin, for local-time scheduling. The default minimum interval is 5 minutes and can be changed with LUDASH_MIN_SCHEDULE_INTERVAL_MINUTES.
The Upgrade All Systems dialog can save an ordered flow with optional groups. Systems in the same group run together; the next group starts only after the current group finishes. Hidden systems and systems excluded from Upgrade All are not queued unless explicitly included.
Set a refresh schedule's cache duration to 0 to disable cache reuse. Manual refreshes, server restarts, and newly added systems can still trigger checks outside configured schedules. Notification channels can be assigned to multiple notification schedules.
Notification channels are configured from the Notifications page. Each channel can choose event types (updates, unreachable, and appUpdates), system scope, immediate or scheduled delivery, and provider-specific settings. New channels default to updates and appUpdates. Secrets such as SMTP passwords, app tokens, bot tokens, command tokens, webhook credentials, and sensitive headers are encrypted at rest.
| Type | Best for | Notes |
|---|---|---|
Email |
inbox alerts | SMTP with optional auth, Plain SMTP, STARTTLS, or implicit TLS. Prefer NODE_EXTRA_CA_CERTS for private CAs. |
Gotify |
self-hosted/mobile push | App token stored encrypted. |
MQTT |
brokers, automations, Home Assistant | Generic event publishing plus optional Home Assistant MQTT Update entities. |
ntfy |
lightweight push topics | Topic delivery with optional bearer token. |
Telegram |
chat alerts and optional commands | Private-chat binding only. Commands are off by default and require confirmations for mutating actions. |
Webhook |
custom integrations, chat ops, n8n, Node-RED, Discord | POST/PUT/PATCH, templates, query params, headers, auth, retries, timeout, and Discord preset. |
LUDASH_BASE_URL should be explicitly set for Home Assistant. The MQTT integration can publish one app update entity and one per-system package update entity. Discovery config uses retained payloads; update state and JSON attributes are published on separate retained topics; install commands map to the normal per-system upgrade action when enabled. Notification schedules only affect the generic MQTT event topic, not Home Assistant state.
Per-system attributes include update counts, security counts, reboot state, reachability, active operation, detected host metadata, and pending package details. Package entity versions are synthetic fingerprints for the pending update set, not real package-version pairs.
Telegram notification setup:
- Create a bot with @BotFather.
- Create a
Telegramchannel in the dashboard and paste the bot token. - Save, reopen the channel, click Create Link, and open the generated private-chat link.
- Start the bot in Telegram and use Send Test to verify delivery.
Bindings use single-use deep links that expire after 10 minutes and show unbound, pending, or bound status. Changing the bot token clears the binding.
Optional commands are private-chat-only, disabled by default, scoped to the channel's systems, and backed by an auto-generated write-capable API token. The token is revoked when commands are disabled, the chat is unlinked, the channel is deleted, or the bot token changes. Supported commands are /help, /version, /menu, /status, /refresh <system-id|all>, /packages <system-id>, /upgrade <system-id|all>, /fullupgrade <system-id|all>, and /upgradepkg <system-id> <package>. Mutating commands require confirmation buttons that expire after 5 minutes.
Webhook channels support POST, PUT, and PATCH; custom or Discord presets; no auth, bearer auth, or basic auth; text, JSON template, or form bodies; query params; custom headers; timeout; retries; retry delay; and optional insecure TLS for trusted internal targets.
Templates use simple Mustache variable tags. Only dotted event.* paths are allowed; sections, loops, and other Mustache control tags are rejected. Common variables include {{event.title}}, {{event.body}}, {{event.priority}}, {{event.sentAt}}, totals under {{event.totals.*}}, text summaries, JSON payload helpers, and JSON-safe variants such as {{event.titleJson}} and {{event.decoratedTitleJson}}.
Webhook delivery defaults to a 10-second timeout, 2 retries, and a 30-second retry delay. URLs must be http or https, embedded URL credentials are rejected, cloud metadata endpoints are blocked, and reserved headers such as Authorization, Host, Content-Length, Connection, and Cookie cannot be set manually.
The Scripts page exposes the SSH command templates for package-manager detection, update checks, installed-package inventory, issue repair, autoremove, upgrades, selected-package upgrades, system info, and reboots.
- Built-in scripts for APT, DNF, YUM, Pacman, APK, Flatpak, Snap, system-info, and reboot are read-only and can be copied.
- Custom scripts can define shell steps, operation type, parser settings, installed-package inventory parsing, and system-info section mapping.
- Per-system overrides can replace individual operations such as
apt/check_updates,apt/repair_issue,apt/autoremove,apt/upgrade_all, orsystem/reboot. - Custom package managers can define display labels, parser regexes, optional config entries, and import/export bundles.
- Used scripts and package managers are protected from accidental deletion.
Command placeholders include {{package}}, {{packages}}, {{quotedPackage}}, {{quotedPackages}}, {{manager}}, {{config.someKey}}, and {{sudo:COMMAND}}. Custom parser regexes should use named capture groups such as packageName, newVersion, currentVersion, architecture, and repository.
For restricted automation accounts, use a dedicated SSH user and leave the dashboard sudo password unset. The Systems page has a Sudoers setup action that generates a least-privilege /etc/sudoers.d allowlist for the selected system. Review generated files before installing them, keep commands exact, prefer absolute executable paths, leave selected-package wildcard rules commented unless needed, and avoid broad rules such as NOPASSWD: ALL or sudo sh <writable-script>.
Example validation after installing a generated file:
sudo chmod 440 /etc/sudoers.d/updater-updater
sudo visudo -cf /etc/sudoers.d/updater-updaterPackage maintenance remains privileged because package scripts run as root. The goal is to limit the dashboard account to the required maintenance commands.
Upgrade operations and autoremove run through detached remote scripts with nohup, so they continue if SSH disconnects or the dashboard restarts. The dashboard streams output with tail --pid, captures the exit code from a companion file, and marks setup failures as failed instead of falling back to unsafe direct execution. If monitoring disconnects mid-operation, the remote process may still be running and the UI reports that warning.
| Operation | SSH-safe |
|---|---|
| Upgrade all packages | Yes |
| Full/dist upgrade | Yes |
| Upgrade selected packages | Yes |
| Autoremove unused packages/runtimes | Yes |
| Check for updates | No, read-only and retryable |
| Reboot | No, fire-and-forget |
If a sudo password is configured, it is sent only over the live SSH stdin stream to the privileged command. It is never written to files or environment variables. SSH-safe operations are marked with an SSH-safe badge in activity history.
The dashboard records /etc/os-release fields during refreshes and compares them with a bundled lifecycle catalog. Warnings appear on the dashboard, systems list, and system detail page when a release is near EOL, fully EOL, or in reduced support such as Debian LTS after regular Debian Security Support.
The warning window is configurable from Settings > Lifecycle Warnings and defaults to 180 days. Supported catalog keys are ubuntu, debian, fedora, rhel, rocky, almalinux, centos, centos-stream, alpine, and proxmox.
Lifecycle data is generated at build time by scripts/generate-distro-lifecycle-data.mjs from https://endoflife.date/api. The generated data lives in server/generated/distro-lifecycle-data.json, with server/default-distro-lifecycle-catalog.json as an offline fallback. pnpm run build runs the generator before compiling and keeps or falls back to local data if the remote API is unavailable.
Use a custom source catalog for repeatable builds:
LUDASH_EOL_CATALOG_FILE=/path/to/my-eol-catalog.json pnpm run generate:eol
# or
LUDASH_EOL_CATALOG_FILE=/path/to/my-eol-catalog.json pnpm run buildCustom catalogs use the same shape as server/default-distro-lifecycle-catalog.json; each entry needs cycle and eol, with optional supportEnd, supportLabel, and finalSupportLabel.
If you edit server/generated/distro-lifecycle-data.json directly, remember that pnpm run build regenerates it. Use LUDASH_EOL_CATALOG_FILE for repeatable custom data.
For container installs, set LUDASH_LOG_LEVEL=debug and inspect logs:
docker logs -f linux-update-dashboardAt info, logs include startup, configuration, warnings, and errors. debug adds attempt-scoped SSH diagnostics and routine scheduler refresh logs. Failed test-connection requests include a debug reference ID you can match against logs.
Logged SSH diagnostics are limited to safe metadata such as host, port, username, auth type, elapsed time, and filtered auth/debug events. Passwords, sudo passwords, private keys, passphrases, tokens, and raw SSH payloads are never logged. Avoid leaving debug logging enabled longer than needed.
- Credentials and notification secrets are encrypted at rest with AES-256-GCM and per-entry random IVs/auth tags.
- Encryption keys can be raw base64 or passphrases derived via PBKDF2-SHA256 with 480k iterations and a per-instance salt.
- Sessions use HTTP-only, SameSite=Lax JWT cookies signed with HS256.
- State-changing API requests require a per-session CSRF token header.
- API inputs use strict type, format, and range validation.
- Auth endpoints are rate-limited: setup 3/min, login and WebAuthn verify 5/min, and failed bearer token attempts 20/min per IP.
- API tokens are hash-only, can be read-only, skip CSRF as stateless bearer requests, and are blocked from management endpoints and SSH connection configuration.
- Password login uses timing-safe dummy-hash comparisons to reduce username enumeration risk.
- OIDC client secrets are encrypted at rest and can use private/self-signed CAs through
NODE_EXTRA_CA_CERTS. - Per-system mutexes and a semaphore-based SSH connection limit prevent conflicting or runaway SSH operations.
- Client: React, React Router, TanStack Query, Vite, Tailwind CSS in
client/; production output goes todist/client. - Server: Node.js and Hono in
server/; compiled output goes todist/server. - Database: SQLite with Drizzle schema/migrations; startup creates and upgrades schema automatically.
- SSH:
ssh2connection manager, package-manager parsers, command scripts, output streaming, ProxyJump support, and host-key validation. - Runtime services: scheduling, caching, notification delivery, MQTT/Home Assistant state, app update checks, and distribution lifecycle resolution.
├── .github/ # CI/CD workflows and Dependabot
├── assets/ # Logo assets served by the app
├── client/ # React SPA, routes, hooks, components, styles, locales
├── docker/ # Dockerfile, compose, entrypoint, test systems
├── examples/ # Custom package-manager examples
├── screenshots/ # README screenshots and capture scripts
├── scripts/ # Build-time helper scripts
├── server/ # Hono backend, auth, db, routes, services, ssh
├── tests/server/ # Vitest server and integration tests
├── run.sh # Local dev/production/test runner
├── reset-dev-branch.sh # Reset dev branch to main
├── vite.config.ts # Vite + Tailwind config
└── package.json
Use the helper:
./run.sh dev # hot reload, backend :3001, client :5173
./run.sh # build and start production server on :3001
./run.sh test # production server plus Docker test systemsOr use pnpm directly:
pnpm run dev
pnpm run dev:server
pnpm run dev:client
pnpm test
pnpm run check./run.sh test stops running services, builds and starts 17 Docker SSH fixtures, builds the frontend, and starts the production server on :3001. Add test systems in the dashboard with host.docker.internal or 172.17.0.1 on Linux and the matching SSH port.
Shared credentials:
- User
testuser, passwordtestpass Sudo password:testpassforludash-test-ubuntu-sudoandludash-test-debian-fish-sudo, optional for most othersludash-test-ubuntu-rootalso acceptsroot/testpassfor root-login banner testing
| Container | SSH Port | Package Manager / Fixture | Base |
|---|---|---|---|
ludash-test-ubuntu |
2001 | APT | Ubuntu 24.04 |
ludash-test-fedora |
2002 | DNF | Fedora 41 |
ludash-test-centos7 |
2003 | YUM | CentOS 7 |
ludash-test-archlinux |
2004 | Pacman | Arch Linux |
ludash-test-flatpak |
2005 | Flatpak | Ubuntu 24.04 |
ludash-test-snap |
2006 | Snap | Ubuntu 24.04 |
ludash-test-ubuntu-sudo |
2007 | APT with sudo password | Ubuntu 24.04 |
ludash-test-debian-fish |
2008 | APT with fish shell | Debian 12 |
ludash-test-debian-fish-sudo |
2009 | APT, fish, sudo password | Debian 12 |
ludash-test-alpine |
2010 | APK | Alpine 3.16 |
ludash-test-apt-keptback |
2011 | APT kept-back fixture | Debian 12 |
ludash-test-apt-snap-partial |
2012 | APT + failing Snap fixture | Ubuntu 24.04 |
ludash-test-dnf-gpg-prompt |
2013 | DNF GPG key prompt fixture | Fedora 41 |
ludash-test-dnf-eula-prompt |
2014 | DNF EULA prompt fixture | Fedora 41 |
ludash-test-apt-dpkg-interrupted |
2015 | APT interrupted dpkg fixture | Debian 12 |
ludash-test-ubuntu-root |
2016 | APT root-login/full sudo fixture | Ubuntu 24.04 |
ludash-test-custom-package-managers |
2017 | npm/pip/pipx custom fixtures | Ubuntu 24.04 |
Fixtures pin older package versions from archived or local repositories while current repos stay active, so package-manager checks report deterministic pending updates. Special fixtures cover kept-back packages, partial multi-manager failures, DNF GPG prompts, DNF EULA prompts, interrupted dpkg repair, root-login behavior, and custom package manager examples in examples/.
Most test users are restricted through least-privilege sudoers allowlists in docker/test-systems/sudoers/. Selected-package operations use reviewed argument wildcards only where a fixture needs them.
Docker integration tests are opt-in, for example:
LUDASH_RUN_DOCKER_INTEGRATION=1 pnpm vitest run tests/server/apt-sudo-password.integration.test.tsReset dev to main and force push:
./reset-dev-branch.shTranslations live in client/locales/. Browser-language detection applies to the UI. Server-generated text such as notifications and scheduled messages uses the saved language setting; with Browser default, background server text falls back to English because no browser locale is available.
Contributions for corrections, more natural wording, and new languages are welcome. Keep the same keys as client/locales/en.json.












