The first useful security question in a homelab is not “which scanner should I install?”. It is “what is actually reachable from the internet?”.
This guide collects everything I learned while hardening my own self-hosted stack, in the order it proved useful: know what is exposed, minimize what it can reach, keep tested backups, patch deliberately, and leave enough evidence that future maintenance does not depend on memory.
It ends with a real-world case study, a Nextcloud major-version migration, where most of these controls met in a single maintenance window.
This becomes more relevant every month. For a concrete example, see The PHP Drama and the SSL Certificate Week: an emergency PHP fix and a certificate-authority incident landed in the same week, and neither was solved by sitting behind Cloudflare.
The Short Version
| Layer | Goal | Tools | Section |
|---|---|---|---|
| Inventory | Know every public route and the container behind it | Docker, Trivy | 1 |
| Containment | Limit what a compromised service can do | Docker Compose | 2 |
| Exposure | Verify ingress matches intent | Cloudflare Tunnel | 3 |
| Recovery | Make every change recoverable | DB dumps, checksums, image digests | 4 |
| Deep scanning | Get a second and third opinion | Syft, Grype, OSV-Scanner, Gitleaks | 5 |
| Host | Fix the foundation under the containers | Lynis | 6 |
| Runtime | Detect and contain a compromise | Falco, Wazuh | 7 |
| Routine | Turn it all into a habit | All of the above | 8 |
How They Fit Together
Public hostnames
-> Inventory: which routes are live, and which container serves each one
-> Trivy: scan the exact deployed images, triage Critical/High
-> Containment: networks, capabilities, mounts, no Docker socket
-> Cloudflare Tunnel: configured routes vs. what is actually reachable
-> Backups: database + files + config + rollback digest before any change
-> Syft / Grype / OSV-Scanner / Gitleaks: SBOM, lockfiles, secrets
-> Lynis: harden the host underneath
-> Falco / Wazuh + response plan: assume one container falls
-> Document, then repeat on a schedule
Not every homelab needs every layer on day one. Start with the inventory and a Trivy pass; they tell you where the rest of the effort is worth spending.
All hostnames, container names and image tags below are placeholders. Replace them with your own and scan the exact deployed tag or digest, not a similarly named candidate.
1. Scan What Is Internet-Facing
Before any scanner runs, build an inventory. Everything else depends on it.
Build an Exposure Inventory
For each public hostname, record:
- the public name;
- the tunnel or reverse-proxy origin;
- the container behind that origin, and its port;
- the purpose of the service;
- whether the route is intentionally enabled right now.
That last column immediately exposes an important distinction: a service can exist and run perfectly while its public route is disabled. Without the inventory, it is easy to patch the wrong image or forget a paused route entirely.
# Inventory running containers and their images
docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Ports}}'
# Test the public route separately from the local origin
curl -I https://service.example.test
curl -I http://127.0.0.1:8080
Scan the Exact Deployed Image
I started with Trivy . The result was never a simple secure/insecure label: some services had no Critical findings, some had High findings inherited from a base image, and two application images needed real dependency work before deployment.
# Scan the exact deployed image and save JSON for comparison
trivy image --severity CRITICAL,HIGH --ignore-unfixed example/app:current
trivy image --format json --output trivy-example-app.json example/app:current
Triage and Record Decisions
Separate Critical, High, and lower-severity findings, then decide per finding. Context matters:
- A High finding in an unused route is different from the same finding in an authenticated public application.
- A Critical finding in build-only tooling deserves investigation, but should not be silently ignored just because the scanner found it in an unusual path.
Write the exceptions down. The inventory and the decision record are as valuable as the scan output.
Change Safely
The fix sequence that proved useful:
- Back up data and configuration before changing anything.
- Build a candidate image without replacing production.
- Scan and smoke-test the candidate locally.
- Deploy only after the result and rollback path are understood.
- Check logs, health endpoints, and login flows afterward.
Vulnerability management is a process, not a one-off command. A clean scan today does not replace image pinning, update discipline, backups, or runtime controls tomorrow.
2. Contain Docker Services
Containers package applications and make recovery repeatable. They are not magical security bubbles.
Patching prevents known exploitation; containment limits blast radius when prevention fails. These controls do not make a vulnerable application safe, but they reduce what an attacker can do after an application-level failure.
Limit Network Scope
An application should join only the networks it needs. A public web app usually needs its database or cache, but not every other application in the homelab. The tunnel connector should not become a bridge to unrelated internal services either.
Reduce Privileges
- Do not mount the Docker socket unless there is a very strong reason.
- Avoid privileged containers.
- Drop unnecessary Linux capabilities.
- Use a non-root user where the application supports it.
- Add
no-new-privileges. - Make the filesystem read-only where practical.
- Mount only the persistent directories the application actually needs.
- Avoid host ports when a private Docker network is enough.
Apply Changes Incrementally
Inspect the compose file and container mounts first, then remove one unnecessary permission at a time and test. Databases, upload directories, caches, and plugin systems often need different write permissions, so a blanket read-only setting may require careful exceptions.
# Inspect mounts, networks, user, and privilege settings
docker inspect example-app --format '{{json .Mounts}}' | jq
docker inspect example-app --format '{{json .NetworkSettings.Networks}}' | jq
docker inspect example-app --format 'user={{.Config.User}} privileged={{.HostConfig.Privileged}}'
docker port example-app
# Validate a compose file before recreating services
docker compose config --quiet
The target is not an impressive compose file. It is a service that still works with fewer paths to the host, fewer neighbors to discover, and fewer privileges to abuse.
3. Audit Cloudflare Tunnel Exposure
Cloudflare Tunnel is a great way to publish a homelab service without opening inbound router ports. It is an exposure control, not a security finish line.
Configured vs. Actually Reachable
The tunnel configuration is an allowlist of intended routes, not proof that every route is safe. Run two separate checks:
- What does the tunnel configuration say should be exposed?
- What does an external request actually reach?
A route can point at the wrong container, or be reachable when it was meant to be paused. Conversely, a deliberately disabled route may return a proxy error while its container stays healthy internally. That is an expected security control, not an outage.
Treat the Connector as Sensitive Infrastructure
The cloudflared connector should have limited network visibility, a current image, protected credentials, and no Docker socket access. Cloudflare Access, authentication, rate limits, and origin-level authorization remain valuable for administrative services.
Test After Every Route Change
Query the route from outside, check the local origin separately, and record the result. This prevents confusing a tunnel problem with an application problem.
# Inspect the connector and its network visibility
docker ps --filter name=cloudflared --format 'table {{.Names}}\t{{.Image}}\t{{.Ports}}'
docker inspect cloudflared --format '{{json .NetworkSettings.Networks}}' | jq
# Check the configured tunnel
cloudflared tunnel list
cloudflared tunnel info <tunnel-name>
# Test actual external and internal reachability
curl -sS -o /dev/null -w '%{http_code}\n' https://service.example.test
curl -sS -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080
Never paste tunnel tokens into notes or shell history. For a full setup, see the Cloudflare Tunnel guide and the Secure External Access decision map.
4. Backups Make Upgrades Safe
“Just upgrade it” is not a strategy for stateful software. The safe version starts with understanding what must survive.
Know What Must Survive
For a database-backed application, that usually means the database, configuration, secrets, uploaded files, and any application-specific persistent directory.
- A database dump alone may restore accounts and settings but leave the service without its documents.
- A file copy alone may preserve documents while losing metadata and permissions.
Pre-Change Checklist
- available space on the destination disk;
- backup size and completion status;
- checksums where practical;
- database dump readability;
- the old image tag or digest;
- the exact rollback command;
- a maintenance window for login and health checks.
“A backup exists” is not the same as “a restore is possible”. Large copies take time and can consume more space than expected, so check the destination and monitor progress. Keep the backup separate from live data, and ideally replicate it to another disk.
Rollback Tags Are Not Backups
Rollback image tags preserve the known image, but a newer application may have migrated the database schema. Simply running the old image may not reverse that migration.
# Illustrative database dump; use the correct client for your database
docker exec database-container sh -c 'exec mysqldump -u"$MYSQL_USER" -p"$MYSQL_PASSWORD" "$MYSQL_DATABASE"' > database-before-upgrade.sql
ls -lh database-before-upgrade.sql
sha256sum database-before-upgrade.sql > database-before-upgrade.sha256
# Capture the image reference for rollback
docker inspect --format '{{.Config.Image}}' application-container
docker image inspect example/app:current --format '{{index .RepoDigests 0}}'
The safe sequence is backup, candidate test, deploy, verify, and retain the old state until the application has passed real checks. Backups do not remove risk. They make it recoverable.
5. Layer Your Vulnerability Scanners
Security scanners are not interchangeable. They answer different questions.
What Each Tool Answers
| Tool | Question it answers |
|---|---|
| Trivy | Broad first pass: OS packages, app libraries, config, some secrets |
| Syft | What is inside this image? (SBOM) |
| Grype | Which known vulnerabilities affect those components? |
| OSV-Scanner | Are my dependency lockfiles vulnerable? |
| Gitleaks | Are credentials leaked in files or git history? |
Why Results Disagree
An image scan may report a clean runtime image while OSV-Scanner flags a vulnerable package in the lockfile. A scanner may find a Go standard library in embedded build metadata. A High finding may come from a Debian base package rather than the application itself.
Do not pick the most convenient result. Investigate the component, its runtime path, exploitability, and the available fixed version.
A Useful Scanning Sequence
- Scan the exact deployed image.
- Generate an SBOM for the candidate.
- Scan the SBOM with a second database/tool.
- Scan source dependency lockfiles.
- Scan repository history for secrets.
- Record disagreements and decisions.
syft example/app:current -o cyclonedx-json=example-app.sbom.json
grype sbom:example-app.sbom.json --output table
osv-scanner scan source --lockfile package-lock.json
gitleaks git --redact --report-format json --report-path gitleaks.json .
Save output with the image tag, scan date, and tool version. Otherwise a later scan cannot explain whether the result changed because of the image, the database, or the scanner. Reports can contain internal paths even when they contain no secrets.
6. Harden the Host with Lynis
The container may be the visible application, but the host is the foundation. If the host is poorly maintained, an excellent compose file cannot compensate.
Lynis audits Linux configuration, packages, kernel state, SSH, services, permissions, and auditing. Treat its output as a backlog, not a list of commands to run blindly.
sudo lynis audit system --no-colors \
--log-file /tmp/lynis.log \
--report-file /tmp/lynis-report.dat \
| tee /tmp/lynis-output.txt
grep -E '^(Warning|Suggestion)' /tmp/lynis-output.txt
sudo less /tmp/lynis-report.dat
Easy Wins
Reboot after a kernel update, install security updates, review stale packages, and remove old kernels when appropriate.
Findings That Need Context
- Services: suggestions about CUPS, rpcbind, ModemManager, or Avahi may be right for a server that does not use them, but disabling them blindly can break hardware or workflows.
- SSH: X11/agent/TCP forwarding, login retry limits, idle timeouts, and root login all trade usability against exposure. An interactive homelab host may need a different policy from a dedicated server.
- Extra tooling:
auditd, fail2ban, and file-integrity tools add visibility but also add services to maintain.
The right question is not “can I maximize the hardening score?”. It is “which controls reduce the risks I actually have, and can I operate them reliably?”. I keep the raw report and mark each recommendation as fixed, accepted, or pending, making changes during a maintenance window.
7. Plan for a Compromised Container
The uncomfortable but useful question: if one public container is compromised, what can it reach next? The answer should not be “everything”.
Limit Lateral Movement
Separate application networks, private database connections, protected secrets, minimal mounts, and no Docker socket access are the practical foundations (see section 2).
Add Runtime Visibility
Falco watches for suspicious container and host behavior such as unexpected shells, sensitive file access, or unusual processes. Wazuh combines endpoint monitoring, log collection, file-integrity checks, and alerting.
Neither replaces patching, and neither should be installed without a plan for reviewing alerts. Signals worth watching:
- a container spawning an unexpected process;
- changes to application or system files;
- access to credentials outside the expected mount;
- new outbound connections;
- changes to container privileges or networks;
- repeated authentication failures.
Have a Response Plan
When an alert arrives: isolate the service, preserve logs, revoke exposed credentials, inspect neighboring services, restore from a known-good backup, and only then reconnect the route.
# Preserve evidence before restarting or removing a suspect container
docker logs --since 24h example-app > example-app-incident.log
docker top example-app
docker inspect example-app > example-app-inspect.json
# During deliberate incident response, remove its public network access
docker network disconnect public-network example-app
Record the original network membership first; the final command changes runtime state. A documented response beats a sophisticated alert nobody knows how to interpret.
8. Make It a Repeatable Workflow
The individual controls are useful, but the payoff comes from connecting them into a routine.
The Routine
- Inventory: list public routes, container origins, images, volumes, and data stores.
- Scan: run image, dependency, SBOM, secret, and host checks.
- Triage: prioritize Critical findings, then High findings with real exposure and available fixes.
- Back up: save databases, files, configuration, checksums, and rollback image references.
- Build: create a candidate image without replacing the live service.
- Verify: scan it, test startup, inspect logs, and exercise login and uploads.
- Deploy: make one controlled change and retain the previous state.
- Observe: check local and public health, logs, authentication, and resource usage.
- Document: record what changed, what remains open, and how to roll back.
Do Not Skip “Document”
A master security note turns scattered commands into institutional memory: which routes are intentionally disabled, which findings are accepted, where backups live, and what must happen before the next upgrade.
The Recurring Command Set
docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}'
trivy image --severity CRITICAL,HIGH example/app:current
lynis audit system --quick
syft example/app:current -o cyclonedx-json=app.sbom.json
grype sbom:app.sbom.json
osv-scanner scan source --lockfile package-lock.json
gitleaks git --redact --no-banner .
Save reports with a date and image digest so the next run can be compared meaningfully.
Case Study: Upgrading Nextcloud Without Losing the Instance
Nextcloud is an application where “pull the latest image” is not an upgrade plan. The container holds the PHP runtime and application code; the persistent mount holds configuration, bundled apps, uploads, and user data. A major upgrade has to keep those layers compatible.
This migration went from Nextcloud 32.0.5 to 35.0.2. It succeeded, but only after several failures made the operational boundaries visible. This is the condensed version; the full write-up with every step and failure is in How to Upgrade Nextcloud to Avoid the PHP2Shell Vulnerability.
The Upgrade Path
Major releases were upgraded sequentially:
32.0.5 -> 33.0.3 -> 34.0.5 -> 35.0.2
The final image reported Nextcloud 35.0.2.2 and PHP 8.5.12. Do not skip major versions. Change the Compose image, pull, recreate only the application service, run the upgrade, and verify before the next step.
docker compose -f 2602_docker-compose.yml pull nextcloud-app
docker compose -f 2602_docker-compose.yml up -d nextcloud-app
Backups Are Part of the Upgrade
The MariaDB dump was transaction-consistent, but a database dump is not a complete rollback. Preserve:
- the database dump;
config/, especiallyconfig.phpand instance-specific integrations;- the Nextcloud data directory;
custom_apps/and any local application code;- the application tree when the deployment bind-mounts
/var/www/html; - secrets and the exact Compose/image versions.
test -f backup/html/config/config.php
test -d backup/html/data
du -sh backup/html
A large full backup on the same filesystem as the live instance filled the disk. Logins then failed because session and token writes could not complete. Nothing was corrupted; there were simply no free blocks.
df -h
du -sh /path/to/nextcloud
docker system df
For a large instance, send the backup to another filesystem from the start.
When the Image and Mount Disagree
The mounted application tree kept stale code from an earlier release: the 33 image reported a missing Composer file under 3rdparty/, and old bundled apps caused a type error during the 34 upgrade. The fix was to synchronize apps/ and 3rdparty/ from the exact image being deployed.
replace or synchronize: application code, bundled apps, 3rdparty
preserve: config, data, custom_apps, instance integrations
Permissions Matter During Recreate
A second instance ran as UID 1000:1000 and could not replace root-owned files left by the old image. The entrypoint’s rsync failed and the container restart-looped. A temporary root helper container synchronized only apps/ and 3rdparty/.
- Do not make the whole data tree broadly writable to push an upgrade through.
- Distinguish application-code ownership from user-data ownership.
- Repair only the code directories the image must replace.
Migration Errors Are Not Always New Damage
The 33 upgrade reported oc_webhook_tokens already existed; the 35 upgrade reported the same for oc_sharing_classmap. In both cases the schema was already in place, and a retry finished cleanly (needsDbUpgrade: false). Inspect schema and migration state before deleting tables or replaying destructive commands.
Verify the Runtime, Not the Tag
As covered in the PHP and SSL certificate article, check the runtime inside the serving container:
docker exec nextcloud php occ status --output=json
docker exec nextcloud php -v
curl -fsS http://127.0.0.1:8099/status.php
docker inspect nextcloud --format '{{.Config.Image}}'
Confirm the app is installed, maintenance mode is off, needsDbUpgrade is false, the expected Nextcloud and PHP versions are active, status.php returns HTTP 200, and the container uses the intended image. “Container is running” is not the same as “Nextcloud is upgraded”.
Cloudflare Tunnel Is a Separate Layer
The cloudflared connector was upgraded independently: pulled, version and digest checked, only that service recreated, then both routes tested.
Cloudflare Tunnel = public ingress and connector runtime
Nextcloud = application, PHP runtime, database, and files
Updating Cloudflared does not patch PHP, and updating Nextcloud does not update the tunnel connector. Verify each layer on its own.
Choosing a Starting Point
| If You Want To… | Start With |
|---|---|
| Find out what is actually public | Exposure inventory + curl from outside |
| Get a first vulnerability picture | Trivy on deployed images |
| Reduce blast radius without changing apps | Docker containment |
| Upgrade a stateful app safely | Backups, then the Nextcloud case study |
| Check source code and repos | OSV-Scanner + Gitleaks |
| Audit the Linux server itself | Lynis |
| Get alerted on suspicious behavior | Falco or Wazuh |
Related Guides
- Docker 101 for container fundamentals
- Secure External Access for choosing between Cloudflare Tunnel, VPNs, and reverse proxies
- Cloudflare Tunnel with Docker for the full tunnel setup
- Linux Setup 101 for the host baseline
FAQ
Which scanner should I start with?
Does Cloudflare Tunnel make my services secure?
Is a database dump enough to roll back?
Can I skip Nextcloud major versions if the Docker image is available?
Why did login fail after the Nextcloud upgrade?
df -h before revoking tokens or changing application data.
Should I aim for a perfect Lynis score?
Conclusion
The goal is not a permanently perfect scan. Images, operating systems, vulnerability databases, and threat models change. The goal is to notice changes early and make recovery boring.
For a small homelab, that is a realistic security standard: know what is exposed, minimize what it can reach, keep tested backups, patch deliberately, and document enough that the next maintenance window does not depend on memory.
Comments