01 Overview
The homeserver runs ~46 containers, hosts family photos and a dental-AI suite, and is reachable from my phone anywhere in the world โ so its security is not an afterthought. This page is the security story of that platform: the hardening built in at each layer, the audit work that found and fixed real weaknesses, and an honest, ongoing hardening project (assessment โ reviewed spec โ staged, revertible execution) that treats the machine's own defenses as something to measure, not to assume.
OS & kernel
Key-only SSH (no passwords, no root login), a hand-rolled sysctl hardening suite, fail2ban, AppArmor, and automatic security patching.
Ingress & firewall
No inbound port-forwarding at all โ remote access is a Tailscale mesh โ behind a default-deny firewall, with the public funnel locked to 404 + one authenticated path.
Application audit
A multi-agent audit across 19 services: killed hardcoded credentials, replaced hand-rolled JWT checks with verified ones, escaped user input, and dropped containers to non-root.
02 Why it matters here
Two facts raise the stakes above a typical hobby server. It holds real personal data โ family photos, a dental-AI suite โ so a breach isn't cosmetic. And it's remotely reachable, because the whole point was to use it from anywhere. The design answer to "reachable from anywhere" was to open zero ports to the internet and put everything on a private Tailscale mesh, so the attack surface from the public internet is essentially one authenticated funnel rather than a wall of exposed services. The rest of the work is defense-in-depth behind that boundary โ and being honest about where the boundary is still soft.
03 How it's hardened, layer by layer
Most of this was built at setup and verified again in this project's read-only assessment.
Fig. 1 โ the layers, outside-in. The headline finding of the hardening project โ the DOCKER-USER firewall for container ports โ is now populated and live on the box.
- Host / SSH โ public-key auth only (
PasswordAuthentication no,PermitRootLogin no, keyboard-interactive off); fail2ban locks out SSH brute-force; AppArmor runs 131 profiles (36 in enforce mode). - Kernel โ a custom sysctl suite in
/etc/sysctl.d/: ICMP redirects off, reverse-path filtering, SYN cookies, BPF-JIT hardening, restricted kptr/dmesg/ptrace, protected sym/hardlinks, IPv6 disabled, broadcast-ping ignored. - Patching โ unattended-upgrades applies security updates automatically.
- Ingress โ no router port-forwards; a Tailscale mesh with split-DNS so the same hostnames resolve at home and away; the public funnel's root returns 404 and its one live path sits behind an auth gate.
- Firewall โ UFW with a default-deny INPUT policy and a per-service allowlist.
- Applications โ the 19-service audit below.
04 ๐ Skills & tech used
05 Notable challenges & findings
The interesting parts were the gaps that looked closed but weren't.
Docker was quietly bypassing UFW
The current assessment's headline finding: the DOCKER-USER iptables chain was empty, which means every container published on 0.0.0.0 was reachable on the LAN regardless of the UFW allowlist โ the firewall was largely cosmetic for containerized services. The fix โ now applied and verified on the live host โ populates DOCKER-USER to admit only LAN + Tailscale + inter-container traffic and drop the rest, making the firewall boundary real. Container ports that don't need the LAN โ the sensitive dashboards and the autonomous-agent tools โ are now locked to the Tailscale interface only.
A duplicate rule that undid an earlier lockdown
An earlier session had restricted the MCP port to the Tailscale interface only. The assessment found a second, later UFW rule that re-opened the same port to "Anywhere" โ silently regressing the lockdown. It's the kind of drift that only a fresh, adversarial re-audit catches; the stray rule has been deleted. (Fixing this also surfaced a subtler trap โ an inline comment in the UFW rules file silently disabled the firewall on reload โ which was caught and corrected.)
Auditing 19 services with a panel of models
An application-layer audit ran two independent model auditors (a codex model and a Gemini model) plus ten parallel agents to apply fixes across 19 services: removed hardcoded credentials and a NOPASSWD:ALL, replaced a hand-rolled base64 JWT decode with real signature verification, fixed an API-key empty-string bypass, escaped user input against XSS, and moved ten container images to a non-root USER. All 19 rebuilt healthy.
Naming the soft spots instead of hiding them
The trust model is deliberate and documented: the internal dashboards run without their own login on a LAN-trust assumption, and the deploy pipeline holds broad privilege because its job is to deploy services and open ports. That's defensible for a single operator on a private tailnet โ and the most exposed pieces have since been tightened. Every sensitive control surface โ the file browser, the system dashboard, the deploy dashboard, and two autonomous-coding-agent tools โ is now Tailscale-only, off the LAN entirely. The deploy pipeline itself was the hardest case: rather than let it rewrite the firewall at will (it held NET_ADMIN and a read-write mount of the firewall config), it now talks to a tiny host-side broker that will only open a port in a bounded range and refuses everything else โ so it can still deploy, but can no longer disable the firewall or touch an arbitrary rule. The one honest remainder is narrowing its home-directory mount, which needs a live deploy to verify safely.
Assessment before action. The whole hardening project started read-only โ inventory what's already hardened (a fair amount), find what regressed or was never real, write a reviewed, revertible spec โ and only then change the live machine. Security work on a system you depend on daily is change-control, not cowboy edits.
06 Security across the portfolio
The homeserver hardening isn't a one-off โ the same defensive habits run through the whole body of work.
Treat every input as hostile
HTML-escaping and path-traversal guards are standard across the apps โ the game hub, BlenderHub, note-summary, the family tools โ and the multi-service audit retrofitted them where they were missing.
Untrusted model output, too
Hallucination guards on Pickflix, defensive JSON parsing on Jellyfin Picks, and โ for autonomous coding agents โ a "compose immune system" plus a git-snapshot / diff / revert gate that stop an agent from wiping a 50-service stack (the benchmark, the deploy pipeline).
Auth where it matters
JWT with single-session enforcement on the game hub, Basic-Auth with a constant-time compare on note-summary, host-PAM on the file browser โ and plainly-labeled LAN-trust where the risk is genuinely low.
Sterilize, don't scatter
A 20+-rule redaction engine sterilizes ops-history before it trains a model (the home-server brain), and keys are redacted in API responses. The same audit surfaced โ and is fixing โ a systemic hardcoded shared-key problem across repos: flagged, not hidden.
Privacy by construction
The dental-narrative tool stores no patient data at all (stateless), note-summary runs a three-layer PHI scrubber, and the family apps ship de-identified clones.
Network segmentation
An isolated IoT VLAN with a zone-based stateful firewall keeps a 3D printer reachable from the LAN but unable to initiate connections back into it.
The honest through-line. The audits that found these strengths also found the gaps โ a shared master key committed across repos, unauthenticated LAN dashboards, a few over-privileged containers. They're now largely remediated: a portfolio-wide credential rotation split the shared key into per-service keys and revoked it โ database and provider keys rotated behind it, a pre-commit secret scanner added โ the container firewall is real, every sensitive dashboard is tailnet-only, and the deploy pipeline can no longer rewrite the firewall at will. One sliver remains (narrowing the pipeline's home-directory mount) โ tracked and honest, not papered over.
07 Results
Sources: read-only assessment of the live host (SSH config, sysctl, UFW, fail2ban, AppArmor, iptables, Tailscale) and the prior audit task-summaries, Aug 2026. Full detail in the project's baseline + spec docs on the server.
08 Artifacts
The project is documented as it runs; captures are placeholders pending a scrub (the real files show host paths and rule detail).
baseline-assessment.md ยท hardening-spec.md ยท changes-applied.md
/etc/sysctl.d/ โ redirects off ยท rp_filter ยท BPF-harden ยท protected links
empty chain โ LAN+tailnet allow, drop the rest
09 Honest status
This is a working, in-use posture, not a certified one. A meaningful amount is genuinely hardened โ key-only SSH, the sysctl suite, default-deny UFW, fail2ban, AppArmor, Tailscale-only ingress, and a real application audit. Most of the spec'd hardening has since been applied on the live host, one change at a time with a before/after check and a logged revert for each: the container-firewall bypass is closed, every sensitive dashboard (file browser, system + deploy dashboards, and two autonomous-agent tools) is Tailscale-only, a portfolio-wide credential rotation split the shared key that leaked across repos into per-service keys and revoked the old one โ with the database password and the provider API keys behind it rotated too, and a pre-commit secret scanner added so new ones can't creep back in โ and the deploy pipeline was de-privileged โ it can no longer rewrite or disable the firewall, only request a bounded port-open through a host-side broker. What's honestly still open is a sliver: narrowing the pipeline's home-directory mount, which needs a live deploy to verify without breaking it, plus moving a few remaining services to loopback-behind-the-proxy. It's a single-operator homelab on a private tailnet; the threat model is "keep the LAN and the one public path honest," not "survive a targeted adversary." Related network work โ an isolated IoT VLAN with a zone-based firewall โ lives on its own page.