diff --git a/containers/libreportal/backend/routes/setup-routes.js b/containers/libreportal/backend/routes/setup-routes.js
index 90ea102..d245e98 100644
--- a/containers/libreportal/backend/routes/setup-routes.js
+++ b/containers/libreportal/backend/routes/setup-routes.js
@@ -56,7 +56,7 @@ function generateInstallName() {
// Grafana follows Prometheus because its datasource points at it.
const INSTALL_TIERS = [
['prometheus', 'grafana'],
- ['traefik', 'crowdsec']
+ ['traefik', 'crowdsec', 'trivy']
];
function sortAppsByTier(apps) {
diff --git a/containers/libreportal/frontend/core/setup/js/setup-wizard.js b/containers/libreportal/frontend/core/setup/js/setup-wizard.js
index 6e1e75d..a49b063 100755
--- a/containers/libreportal/frontend/core/setup/js/setup-wizard.js
+++ b/containers/libreportal/frontend/core/setup/js/setup-wizard.js
@@ -46,7 +46,9 @@ class SetupWizard {
{ slug: 'traefik', recommended: true, defaultChecked: true,
fallback: { name: 'Traefik', description: 'Reverse proxy + automatic SSL via LetsEncrypt' } },
{ slug: 'crowdsec', recommended: true, defaultChecked: true,
- fallback: { name: 'CrowdSec', description: 'Host-installed intrusion prevention' } }
+ fallback: { name: 'CrowdSec', description: 'Host-installed intrusion prevention' } },
+ { slug: 'trivy', recommended: true, defaultChecked: true,
+ fallback: { name: 'Trivy', description: 'Scans your apps for known vulnerabilities (CVEs)' } }
];
}
diff --git a/containers/trivy/docker-compose.yml b/containers/trivy/docker-compose.yml
new file mode 100644
index 0000000..b275847
--- /dev/null
+++ b/containers/trivy/docker-compose.yml
@@ -0,0 +1,43 @@
+networks:
+ DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA
+ external: true
+
+services:
+ trivy-service: #LIBREPORTAL|SERVICE_TAG_1|trivy-service
+ container_name: trivy-service
+ image: aquasec/trivy:latest
+ restart: unless-stopped
+ command: server --listen 0.0.0.0:4954
+ # GLUETUN_OFF_BEGIN
+ ports:
+ - "PORTS_DATA_1" #LIBREPORTAL|PORTS_TAG_1|PORTS_DATA_1
+ # GLUETUN_OFF_END
+ environment:
+ - TZ=TIMEZONE_DATA #LIBREPORTAL|TIMEZONE_TAG|TIMEZONE_DATA
+ - TRIVY_CACHE_DIR=/cache
+ labels:
+ libreportal.category: "CATEGORY_DATA" #LIBREPORTAL|CATEGORY_TAG|CATEGORY_DATA
+ libreportal.title: "TITLE_DATA" #LIBREPORTAL|TITLE_TAG|TITLE_DATA
+ traefik.enable: TRAEFIK_ENABLE_DATA #LIBREPORTAL|TRAEFIK_ENABLE_TAG|TRAEFIK_ENABLE_DATA
+ # TRAEFIK_PORT_1_BEGIN
+ traefik.http.routers.trivy-service.entrypoints: web,websecure
+ traefik.http.routers.trivy-service.rule: Host(`DOMAINSUBNAME_DATA_1`) #LIBREPORTAL|DOMAINSUBNAME_TAG_1|DOMAINSUBNAME_DATA_1
+ traefik.http.routers.trivy-service.tls: true
+ traefik.http.routers.trivy-service.tls.certresolver: production
+ traefik.http.services.trivy-service.loadbalancer.server.port: PORT_INTERNAL_DATA_1 #LIBREPORTAL|PORT_INTERNAL_TAG_1|PORT_INTERNAL_DATA_1
+ traefik.http.routers.trivy-service.middlewares: MIDDLEWARE_DATA_1 #LIBREPORTAL|MIDDLEWARE_TAG_1|MIDDLEWARE_DATA_1
+ # TRAEFIK_PORT_1_END
+ traefik.docker.network: DOCKER_NETWORK_DATA #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA
+ healthcheck:
+ disable: HEALTHCHECK_DATA #LIBREPORTAL|HEALTHCHECK_TAG|HEALTHCHECK_DATA
+ volumes:
+ - SOCKET_DATA #LIBREPORTAL|SOCKET_TAG|SOCKET_DATA
+ - ./trivy-cache:/cache
+ # GLUETUN_OFF_BEGIN
+ networks:
+ DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA
+ ipv4_address: IP_DATA_1 #LIBREPORTAL|IP_TAG_1|IP_DATA_1
+ # GLUETUN_OFF_END
+ # GLUETUN_ON_BEGIN
+ # network_mode: "container:gluetun-service"
+ # GLUETUN_ON_END
diff --git a/containers/trivy/trivy.config b/containers/trivy/trivy.config
new file mode 100644
index 0000000..39de117
--- /dev/null
+++ b/containers/trivy/trivy.config
@@ -0,0 +1,61 @@
+#
+# =============================================================================
+# GENERAL CONFIGURATION
+# =============================================================================
+# APP_NAME = name of application for use in scripts
+# COMPOSE_FILE = default for no app_name in docker-compose file name, app if there is
+# BACKUP = if true, include this application in backup operations
+# HEALTHCHECK = if true, default docker health checks for that container will be enabled
+# AUTHELIA = if true, use Authelia authentication, if false turned off.
+# HEADSCALE = options : false, local, remote (see general config). e.g false or local,remote
+#
+CFG_TRIVY_APP_NAME=trivy
+CFG_TRIVY_BACKUP=false
+CFG_TRIVY_BACKUP_STRATEGY=auto
+CFG_TRIVY_COMPOSE_FILE=default
+CFG_TRIVY_HEALTHCHECK=true
+CFG_TRIVY_AUTHELIA=false
+CFG_TRIVY_HEADSCALE=false
+#
+# =============================================================================
+# METADATA
+# =============================================================================
+# CATEGORY = application category for grouping
+# TITLE = display name for the application
+# DESCRIPTION = short description of the application
+# LONG_DESCRIPTION = detailed description of the application
+# URL = source repository or documentation URL
+# ACTIONS = available actions for this application
+#
+CFG_TRIVY_CATEGORY="security,recommended"
+CFG_TRIVY_TITLE="Trivy"
+CFG_TRIVY_DESCRIPTION="Vulnerability scanner"
+CFG_TRIVY_LONG_DESCRIPTION="Trivy checks the container images behind your installed apps for known vulnerabilities (CVEs) and powers the Security view in the App Center Overview. Scanning happens entirely on your box against a locally cached database — nothing about your apps is sent anywhere"
+CFG_TRIVY_URL="https://github.com/aquasecurity/trivy"
+CFG_TRIVY_ACTIONS="configure|install|restart|shutdown|uninstall"
+#
+# =============================================================================
+# NETWORK CONFIGURATION
+# =============================================================================
+# DOMAIN = number of domain from the general config, useful when using multiple domains
+# WHITELIST = if true only allow whitelisted ips (see general config), if false allow all
+#
+CFG_TRIVY_DOMAIN=1
+CFG_TRIVY_WHITELIST=false
+CFG_TRIVY_NETWORK=default
+#
+# =============================================================================
+# PORT CONFIGURATION
+# =============================================================================
+# PORT_ = port configuration: app|name|external:internal|access|protocol|login|traefik|webui|description
+# - app: application name
+# - name: service identifier (webui, dns, ssh, etc.)
+# - external:internal: port mapping (external can be 'random' for auto-allocation)
+# - access: 'public' (internet accessible), 'private' (local network only), 'disabled' (not running)
+# - protocol: 'tcp' or 'udp'
+# - login: if true, this port requires basic-auth via Traefik (only meaningful when traefik=true)
+# - traefik: if true, Traefik handles this port (reverse proxy)
+# - webui: if true, this port serves the main web interface
+# - description: human-readable description of the service
+#
+CFG_TRIVY_PORT_1="trivy-service|api|random:4954|private|tcp|false|false|false|Scanner API|"
diff --git a/containers/trivy/trivy.svg b/containers/trivy/trivy.svg
new file mode 100644
index 0000000..5336737
--- /dev/null
+++ b/containers/trivy/trivy.svg
@@ -0,0 +1,22 @@
+
diff --git a/docs/roadmap/app-version-updater-and-cve.md b/docs/roadmap/app-version-updater-and-cve.md
index c872d5f..1b52634 100644
--- a/docs/roadmap/app-version-updater-and-cve.md
+++ b/docs/roadmap/app-version-updater-and-cve.md
@@ -75,17 +75,17 @@ Three candidate shapes were on the table:
| Shape | Verdict |
|---|---|
-| **A. Always-on scanner app container** (trivy server / a "security app" in the catalog) | ❌ Resident RAM for a periodic job; permanent docker-socket exposure; duplicates our UI with its own. |
+| **A. Scanner as a catalog app** (trivy in the App Center, recommended alongside CrowdSec) | ✅ **Chosen** (2026-07-14). Installing/removing the scanner is the opt-in switch, visible and revertible like every other app; fits "everything is an app"; sits next to CrowdSec as the second security recommendation at first install. |
| **B. Scanner binary on the host** | ❌ Grows the host footprint we've deliberately kept lean (rootless + de-sudo); another thing to install/update outside the app model. |
-| **C. LibrePortal-orchestrated, ephemeral scanner container** — the scan task does `docker run --rm aquasec/trivy: image --format json ` per installed image | ✅ **Chosen.** No resident process, no host binary, socket exposed only for the seconds a scan runs, rides the existing task/throttle machinery, and the scanner itself is version-pinned like any other image. |
+| **C. Invisible ephemeral container** — the scan task does `docker run --rm` per scan, no catalog presence | ❌ Zero idle cost, but the scanner is invisible: no App Center card, no obvious opt-in/uninstall, and users can't see what has socket access. Visibility won. |
-So: **system-managed orchestration, containerized execution.** Not its own catalog app, not a host package.
+So: **trivy ships as a normal catalog app** (`containers/trivy/`), marked **recommended** and listed in the first-install recommended apps directly under CrowdSec. The updater's CVE step is gated on the app being installed — no trivy app, no scan, honest empty state pointing at the App Center ("Install the security scanner to enable CVE reports").
**Scanner: Trivy** (over grype — both fine, trivy has the larger ecosystem, single pinned OCI image, clean JSON, Apache-2.0). Details:
-- Vulnerability DB cached in a named volume (`libreportal-trivy-cache`, ~600 MB on disk); the DB refresh is the only network traffic.
-- Image access via the rootless docker socket mounted read-only into the ephemeral container (same daemon that owns the images), run as `dockerinstall` like every other docker op.
-- **Privacy posture (worth stating in user-facing copy):** nothing about your images or apps ever leaves the box — Trivy matches locally against a downloaded DB; the only outbound call is the DB fetch from ghcr. Default **on** (it's a security feature and the traffic is one public DB pull), with a single honest switch: `CFG_UPDATER_CVE_SCAN=on|off` (`off` for air-gapped boxes) and `CFG_UPDATER_CVE_INTERVAL` (minutes, **default 1440** — daily; scans are the expensive step, results don't change hourly). Both live in `configs/webui/webui_updater` next to the existing knobs.
+- The app runs `trivy server` (its long-running mode): one modest container that owns the vulnerability DB (~600 MB volume) and keeps it fresh. The updater scan executes scans **through the app's container** (`docker exec trivy image --format json `) — no per-scan container spawn, no host binary, and the DB downloads once instead of per run.
+- Image access via the rootless docker socket mounted read-only into the trivy container (same daemon that owns the images) — the same trusted position CrowdSec-style security tooling already occupies, but here it's a visible, uninstallable app.
+- **Privacy posture (worth stating in user-facing copy):** nothing about your images or apps ever leaves the box — Trivy matches locally against a downloaded DB; the only outbound call is the DB fetch from ghcr. The opt-in is installing the app (recommended, pre-ticked at first install like the other recommendations — skipping it = air-gap-friendly); `CFG_UPDATER_CVE_INTERVAL` (minutes, **default 1440** — daily; scans are the expensive step, results don't change hourly) lives in `configs/webui/webui_updater` next to the existing knobs.
- Output maps straight onto the schema the frontend already renders: per app `cves[] { id, severity, package, fixed_in, url }` + global `totals`. Dedupe per image (shared base layers repeat findings), scan each distinct image once per run. **Drop the `[ ! -f ]` guard** so re-scans overwrite `cves.json`.
- **Not alarmist** (house rule): the Security tab lists everything, but the per-app chip/badge only fires for **critical/high with a fix available** — "your box has 400 unfixable medium CVEs" red badges are noise, not signal.
- **Tie-in with updates:** a CVE whose `fixed_in` is satisfied by the available build marks that update as a *security update* — the Security filter chip and severity sort already exist in the UI, they just start meaning something.
@@ -99,7 +99,7 @@ Once detection + pinned apply are trustworthy, add `CFG_UPDATER_AUTO=off|securit
1. **P1 — Pin foundation.** Factor the compose image-line rewrite into a shared helper (artifact op + updater both use it, all `image:` lines not just the first). Pin-on-install + reconcile-on-first-scan. No UI change yet.
2. **P2 — Real detection.** Registry digest compare in `webuiUpdaterScan` behind the new interval knob; `updates.json` gains services/digests; fleet + per-app tabs light up with truthful data. Emit recovery fields while in there.
3. **P3 — Pinned apply/revert.** `updaterApplyApp`/`updaterRollbackApp` rewrite pins as in §4; history carries digest refs; failure path restores pins. *After this, the Update and Roll back buttons are honest.*
-4. **P4 — CVE scanner.** Ephemeral trivy runs, `cves.json` for real, guard dropped, totals + severity chips live, security-update tie-in.
+4. **P4 — CVE scanner.** The trivy app (catalog + recommended + first-install list) plus the updater's scan step through it; `cves.json` for real, guard dropped, totals + severity chips live, security-update tie-in. The app itself can ship ahead of the rest of P4.
5. **P5 — Polish.** Per-app critical-CVE / update chip on the app header (pattern exists for improvements), remote version-label enrichment, "N services" aggregation in rows.
6. **P6 — Auto-update policy** (§6), only after P3 has soaked on a real install.
diff --git a/scripts/checks/first_install.sh b/scripts/checks/first_install.sh
index a7503a4..db19b1e 100755
--- a/scripts/checks/first_install.sh
+++ b/scripts/checks/first_install.sh
@@ -106,6 +106,7 @@ setupWizardTerminal()
local crowdsec_dashboard="false"
isOption " - traefik (reverse proxy, handles LetsEncrypt SSL)"
isOption " - crowdsec (host-installed intrusion prevention)"
+ isOption " - trivy (scans your apps for known vulnerabilities)"
echo ""
if [[ ${#domains[@]} -eq 0 ]]; then
isNotice "No domains configured — Traefik has nothing to route. Skipping by default."
@@ -125,6 +126,9 @@ setupWizardTerminal()
read -p "" want_console
[[ ! "$want_console" =~ ^[nN]$ ]] && crowdsec_dashboard="true"
fi
+ isQuestion "Install Trivy (CVE scanner)? (Y/n) : "
+ read -p "" want_trivy
+ [[ ! "$want_trivy" =~ ^[nN]$ ]] && apps+=("trivy")
isHeader "Optional Apps"
isQuestion "Install Wireguard (VPN — secure remote access)? (y/N) : "