A self-hosted vulnerability dashboard for the images on your Docker host.
It finds every image on the machine, scans it with Trivy (and Grype if you want a second opinion), and shows you what's affected, how much it matters, and which upgrade fixes the most. Small, quick, and made for homelabs: three containers, no database server, no cloud account.
- Scans everything on its own. New pulls and builds are picked up from Docker events within seconds. Each image shows which containers use it and whether they're running.
- Ranks what matters. Every finding gets a 0–100 risk score from its severity, EPSS (how likely it is to be exploited), the CISA KEV list (known to be exploited), whether a fix exists and whether the image is running.
- Tells you what to do. The fix plan groups findings by package and shows the upgrade that clears them, sorted by how much risk each one removes. Each CVE is traced to the Dockerfile line that added it, so you know whether to bump the base image or fix your own
RUNstep. - Fast rescans. Each image is read from Docker once and stored as an SBOM. Later scans, including the automatic re-check after every database update, run against that SBOM and take well under a second per image.
- See what changed. New and resolved CVEs since the last scan, or since the previous build of the same tag.
- Search across images. Look up a CVE or package and see every image that has it.
- Accepted risks. Mute a finding with a reason and an expiry date. Rules export as OpenVEX for use in CI.
- Alerts. Webhooks for ntfy, Discord, Slack or anything else, sent only for new findings that match your rules.
- Exports. CSV, JSON, SARIF and CycloneDX SBOM per image, plus Prometheus metrics.
- Accounts. Viewer and admin roles, two-factor sign-in with any authenticator app, and API tokens for scripts and dashboards.
- Dark mode, and it works fully offline once the databases are downloaded.
| Image detail | Fix plan |
|---|---|
![]() |
![]() |
| All vulnerabilities | Dark mode |
![]() |
![]() |
| Two-factor setup | Admin panel |
![]() |
![]() |
You need Docker with the Compose plugin. Images are published for amd64 and arm64.
git clone https://github.com/cfunkz/docker-scout-scanner.git
cd docker-scout-scanner
./scanner.sh installThe script creates .env, starts the stack, waits until it's healthy, and then prints:
Dashboard: https://192.168.1.15:8443
Admin panel: https://127.0.0.1:8444 (on this machine only)
Login: admin / hgLt-IllPpYg16-u7f4U
Open the dashboard, accept the self-signed certificate, and sign in. You'll be asked to pick your own password. The first scan starts by itself, and downloading the vulnerability database takes a minute or two the first time.
It uses the prebuilt images from GitHub Container Registry. If they aren't available (or you run ./scanner.sh build), it builds them from the source in this folder, which takes about 10 minutes.
| Command | What it does |
|---|---|
./scanner.sh install |
Start everything and print the URLs and login |
./scanner.sh status |
Show the containers and URLs |
./scanner.sh password |
Reset the admin password and print a new one |
./scanner.sh reset-2fa |
Turn off two-factor sign-in for a user who lost their phone |
./scanner.sh logs |
Follow the logs |
./scanner.sh update |
Pull the latest version and restart. Your data stays |
./scanner.sh build |
Build the images from this checkout and restart |
./scanner.sh stop |
Stop. Your data stays |
./scanner.sh reinstall |
Delete all data and start fresh (asks first) |
./scanner.sh uninstall |
Delete the containers, volumes and images (asks first) |
./scanner.sh agent-certs <host> |
Make the TLS files for watching another Docker host |
cp .env.example .env
docker compose up -d
docker compose logs scanner | grep "admin account"The data lives in the docker-scanner_scanner-data volume. It survives docker compose down and fresh clones, so on a reinstall no new password is generated. Use docker compose exec scanner scanner reset-password admin to get a new one.
Passwords are stored as bcrypt hashes in the data volume, so a password you change in the web UI survives restarts, updates and docker compose down. The scanner never writes your password back into .env in plain text.
If you'd rather manage the admin password from .env, set ADMIN_PASSWORD (12+ characters) and restart. A new value is applied once on the next start, also on an existing install, and signs out that user's sessions. After that the web UI owns the password again until you change ADMIN_PASSWORD to something else. You can clear the line once it has been applied.
You stay signed in for 7 days of inactivity, across browser and server restarts. Change it under Admin → Settings → Stay signed in for. Admin panel sessions end after 8 hours.
The runtipi/ folder is a ready-made Runtipi app. See runtipi/README.md for how to add it to your Runtipi.
Dashboard (https://<server>:8443): overview, images, the vulnerability list, and your own settings: theme, password, two-factor sign-in and API tokens.
Admin panel (https://127.0.0.1:8444): scanner settings, scan controls, users, accepted risks, API tokens of all users, and the audit log. It only listens on the server itself. From another computer, use an SSH tunnel:
ssh -L 8444:127.0.0.1:8444 you@server
# then open https://127.0.0.1:8444Two-factor sign-in: Settings → Two-factor sign-in → Set up. Scan the QR code with Aegis, 2FAS, Bitwarden, Google Authenticator or similar, then save the ten recovery codes. If you lose both, run ./scanner.sh reset-2fa <user> on the server, or ask an admin to use Users → Reset 2FA.
Create a token under Settings → API tokens and send it as a header:
curl -k -H "Authorization: Bearer dss_..." https://192.168.1.15:8443/api/overviewRead-only tokens work on both ports. Admin tokens can also change things, but only on the admin port. That's enough for Homepage or Home Assistant widgets, scripts and CI checks. All endpoints are listed in docs/API.md.
The dashboard is meant to be reachable on your LAN or through a reverse proxy. The admin panel is meant for the server only.
- HTTPS by default. TLS 1.2/1.3 with a self-signed certificate made on first start, or your own. Port 8080 only redirects to HTTPS.
- Sign-in. bcrypt passwords (12+ characters), optional TOTP two-factor with single-use recovery codes, per-address lockout (a guesser locks out only their own address, never the localhost admin panel) and login rate limits. Sessions are random tokens stored as hashes, in
__Host-cookies that areHttpOnly,SecureandSameSite=Strict, with a CSRF token per session. - Admin is separate. The admin API runs on its own listener and port, bound to
127.0.0.1. The dashboard port doesn't have the admin routes at all, so a reverse proxy can't expose them by mistake. - Read-only Docker access. The scanner never touches the Docker socket. A small built-in proxy only lets through reading image and container lists, image metadata and exports, and events. Starting containers,
exec, reading container files and every other write are refused. - Locked-down containers. Non-root where possible, read-only filesystems, all capabilities dropped. The scanner API and the Docker socket proxy listen on unix sockets on shared volumes, not on any network, so no other container can reach them. Only the scanner can reach the internet, and only to download vulnerability data.
- Clean images. The scanner image is built
FROM scratchwith Trivy and Grype compiled from source on the latest Go. The web image isnginx:alpinewith curl and the unused modules removed. At the time of writing, both scan clean apart from Docker daemon CVEs that get reported against the Docker client library inside Grype. SetWITH_GRYPE=falseif you don't need Grype. - Small footprint. About 10 MB of RAM for the scanner and 5 MB for nginx when idle. Images being read during a scan are buffered on disk in the data volume, not in RAM.
SCANNER_MEM_LIMIT(default 1g) caps the scanner. - Audit log of sign-ins, failures, setting changes, user and token changes.
- No telemetry. Offline mode makes no network calls at all.
Point the proxy at https://<server>:8443 and tell the stack which proxy addresses to trust, so logs and rate limits see real client IPs:
# .env
TRUSTED_PROXIES=10.0.0.5 # or a range such as 172.16.0.0/12
DASHBOARD_BIND=127.0.0.1 # if the proxy runs on the same machineCaddy example:
scanner.home.lan {
reverse_proxy https://127.0.0.1:8443 {
transport http {
tls_insecure_skip_verify
}
}
}Traefik and Nginx Proxy Manager work the same way: forward to port 8443 over HTTPS and skip certificate verification (or trust the generated certificate).
If your proxy can only talk plain HTTP to the backend, set HTTP_MODE=proxy. Port 8080 then serves the dashboard without TLS, but only to the addresses in TRUSTED_PROXIES.
It's built for a LAN, but it can sit behind a public reverse proxy. If you do:
- Keep the admin panel off the internet. It only listens on
127.0.0.1anyway; reach it over SSH. - Turn on two-factor sign-in for every account and keep Require sign-in on.
- Set
TRUSTED_PROXIESto your proxy only, so rate limits and lockouts see real client IPs. - For scripts and dashboards, use read-only API tokens with an expiry date.
- Better still, put it behind your proxy's own sign-in (Authelia, Authentik, Cloudflare Access) or a VPN.
One stack watches one Docker host. The simplest way to cover several machines is to install it on each one; it needs about 10 MB of RAM when idle.
To watch a host that shouldn't run the full stack (a NAS, a small VPS), run only the read-only agent there and point a scanner at it. This uses Docker's own tcp:// plus mutual TLS scheme, so nothing new is invented: the agent is the same allowlist proxy the stack already uses, and it only answers clients holding the certificate you generate.
- On the scanner machine, make the certificates (name or IP of the remote host):
./scanner.sh agent-certs nas.lan 192.168.1.20
- Copy
docker-compose.agent.ymland theagent-certs/agent/folder to the remote host, keeping the layout, and start it there:It listens on port 2376. Allow that port only from the scanner's IP in your firewall.docker compose -f docker-compose.agent.yml up -d
- On the scanner machine, set
REMOTE_DOCKER_HOST=nas.lanin.envand run./scanner.sh install.
The first scan of each image copies it over the network once; later rescans use the stored SBOM. To watch the remote host over the internet, use a VPN such as WireGuard or Tailscale rather than opening port 2376.
Put cert.pem (full chain) and key.pem in a folder and mount it instead of the generated one:
# docker-compose.override.yml
services:
scanner:
volumes: ["./certs:/certs"]
web:
volumes: ["./certs:/certs:ro"]Otherwise the generated certificate covers localhost, 127.0.0.1 and the names in TLS_SANS. The install script puts your LAN IP there.
Ports, addresses, the first admin account and similar options go in .env. .env.example explains each one.
Everything else is in Admin panel → Settings and applies without a restart:
| Setting | Default | |
|---|---|---|
| Engine | Trivy | Grype, or both merged |
| Parallel scans | 2 | |
| Full rescan | every 24 h | Images are also re-checked after each database update |
| Scan new images right away | on | |
| Only images used by containers | off | |
| Exclude | none | Patterns such as k8s.gcr.io/* |
| Secret scanning | off | Looks for keys and tokens left in image layers |
| Database update | every 12 h | |
| EPSS and KEV | on | |
| Offline mode | off | |
| Webhook alerts | off | New criticals and new KEV entries by default |
| Require sign-in | on |
- Update:
./scanner.sh update. The database schema upgrades itself. - Backup: everything is in the
scanner-datavolume:docker run --rm -v docker-scanner_scanner-data:/d -v "$PWD":/b alpine tar czf /b/scanner-backup.tgz -C /d . - Disk use: a few hundred KB per image. Admin → Status → Maintenance can forget removed images, prune history and compact the database.
cd backend
go test ./...The UI in web/ is plain JavaScript and CSS with no build step. ./scanner.sh build builds both images from your checkout. Pushing a tag such as v1.0.0 runs the release workflow, which publishes multi-arch images to ghcr.io.
Layout:
backend/ Go API, scheduler, Docker socket proxy (one binary)
web/ dashboard and admin UI
nginx/ TLS, headers, rate limits
runtipi/ Runtipi app files






