Skip to project
pedromartins.tech
Online
Linux Hardening / DevSecOps / Self-Hosted Infrastructure2025 - present

pontosv - Hardened Home Server

A residential Debian box operated like production: zero inbound exposure for its highest-value workload, intrusion detection verified against real and synthetic attacks, a same-day CRITICAL CVE fix, infrastructure-as-code proven idempotent against the live host, and a public dashboard that lets anyone check the claims directly - plus a ten-week network incident, traced to three conflicting owners, fixed for good.

Role
System owner and sole administrator
Environment
Bare-metal Debian · Residential production
Ownership
Detection engineering · Vulnerability management · Infrastructure as code · Observability · Hardware · Linux · Networking · Firewall policy · Containers · Incident response · Runbooks
pontosv - Hardened Home Server project visual
pontosv-ops5 system boundaries
Public Internet
ISP Router
Debian Host
Docker Networks
0inbound ports on the journal workloadOutbound-only via Cloudflare Tunnel; the origin IP isn't in public DNS
6 / 6ssh-bruteforce scenarios verifiedcscli explain confirmed detection without attacking production sshd
8 → 5CRITICAL CVEs on the OJS imageOne root-caused and patched same-day; the rest already scoped for the OJS 3.5 upgrade
0drift on the first Ansible dry runansible-playbook --check --diff reported changed=0 on every task against the live box
13restic snapshots, restore-tested6.8 GB in the B2 repo at 1.74x compression
26 / 78OJS submissions / registered usersReal production client: Revista de Direito do Consumidor, BRASILCON
3 → 1network owners after the fixTwo racing DHCP clients and three overlapping firewall managers, reduced to one of each
0recurrences since the architectural fix
01 / Interactive system

Move through the system layer by layer

pontosv-opsBare-metal Debian · Residential production · Read-only
Online
System map

Boundaries and responsibilities

Public or untrustedAuthenticated boundaryInternal-only dependency
READ-ONLYBare-metal Debian · Residential productiontool: systemselection: wan

Public Internet selected.

02 / Context

The problem behind the system

pontosv is a bare-metal Debian 12 server on a residential connection, run solo as a deliberately operated production platform rather than a home lab.

What's running is not a demo: OJS hosts a real academic journal - Revista de Direito do Consumidor, institutional client BRASILCON - with 26 submissions and 78 registered users as of August 2026, and zero inbound ports (outbound-only via Cloudflare Tunnel, origin IP absent from public DNS). Nextcloud, TeamSpeak 6, and a public status dashboard at sv.pegradowski.com run alongside it.

The box is monitored, detected, patched, and reproducible: CrowdSec verified against real and synthetic attack traffic, a weekly Trivy scan that diffs CRITICAL CVE IDs against the last run instead of alerting on raw totals, an Ansible role proven idempotent against production, and restic backups that are restore-tested rather than merely scheduled.

Earlier in its life the server also survived a ten-week connectivity incident traced to three conflicting network owners - two racing DHCP clients, a rogue DHCP lease, and three overlapping firewall managers - fixed architecturally with zero recurrences since. That incident is covered further down as one entry among several, not the centerpiece.

Read the full project overview

A bare-metal Debian 12 server I built and administer solo - the production machine behind the BRASILCON OJS journal. Intel i5-14400, 32 GiB RAM, 1 TB NVMe, on a residential connection in Curitiba. It is my live laboratory for the discipline that actually matters in security: deciding what gets exposed, why, and proving it.

EXPOSURE AS A DELIBERATE DECISION - The threat model is simple: assume the WAN is hostile and minimize what it can see. The flagship service (the journal) reaches the internet through an outbound-only Cloudflare Tunnel - zero inbound ports for the box's most valuable workload. The only two ports forwarded on the router exist for the self-hosted TeamSpeak 6 voice server (Docker), and each is documented with its justification. Everything else - admin panels, Cockpit, file sharing (NFS/Samba), the web file manager - is LAN-only by policy, with an explicit written rule of what must never be forwarded. SSH is key-only; password authentication is disabled in sshd config.

LEAST PRIVILEGE IN THE SMALL THINGS - The DuckDNS dynamic-DNS updater runs as a dedicated unprivileged system user via a systemd timer, with its token in a root-owned config file at mode 640 - not as a root cron job with the secret inline, which is how most self-hosters do it. TeamSpeak runs containerized with only its two required ports published. The Docker stacks publish no other host ports.

INCIDENT FORENSICS - THE STORY I TELL IN INTERVIEWS - For ten weeks the server "randomly lost internet" every 1–2 weeks, and reboots only reset the clock. Instead of another reboot, I dug through journald and the packet path and found three stacked root causes: two DHCP clients racing on one NIC with independent renew schedules; a rogue DHCP server on the ISP modem that hijacked the default route (the smoking gun was a single journal line at 3:24 AM); and three firewall managers - firewalld, ufw, iptables-persistent - silently fighting each other and Docker, so every previous "fix" had edited a layer that wasn't doing the blocking. The cure was architectural: exactly one owner per layer. One DHCP client, one firewall authority, Docker managing its own chains. Zero recurrences since.

OPERATIONS AS CODE-ADJACENT DISCIPLINE - The server is governed by a living handbook: services-and-ports map, firewall policy with rationale, full incident history with root-cause analysis, an honest risk register, and triage runbooks ordered by likelihood - written so that any competent human or AI agent could operate the machine cold. Documentation is a security control; this box treats it like one.

03 / Security

Threats, controls, and what remains

No control is presented as total risk elimination.

Vulnerability

Unpatched CRITICAL in a running container image

Control
Trivy scans weekly (pontosv-check-trivy.timer) and diffs CRITICAL CVE IDs against the last run instead of alerting on raw totals; a real CRITICAL, CVE-2026-31789 (an OpenSSL heap overflow), was found and patched the same day.
Residual risk
Five CRITICALs remain, tied to the OJS base image; closing them needs the already-scoped OJS 3.5 upgrade, not another scan.
Drift

Manual changes silently diverging from documented config

Control
An Ansible role now codifies the monitoring and backup stack; ansible-playbook --check --diff against the live box reports changed=0, i.e. drift is actually checked, not assumed.
Residual risk
Only the monitoring/backup stack is codified so far - OJS, Nextcloud, TeamSpeak, and network config remain hand-managed and could still drift silently.
Exposure

Accidental service publication

Control
Services-and-ports map plus default-drop host policy
Residual risk
Router configuration remains a separate enforcement layer.
Identity

Password-based SSH attack

Control
Key-only SSH with password authentication disabled, plus CrowdSec detection verified two ways: a real SSH session traced end-to-end through the pipeline, and cscli explain confirming all six ssh-bruteforce scenarios match synthetic attack-shaped input.
Residual risk
No bouncer installed yet, so CrowdSec detects and would alert but doesn't yet auto-block; private-key protection and endpoint security remain necessary.
Availability

Conflicting network managers

Control
Exactly one owner per DHCP and firewall layer
Residual risk
Future package changes can reintroduce competing services.
04 / Decisions

The trade-offs that shaped the build

Decision

Zero inbound ports for the highest-value workload

The OJS journal is reachable only outbound, through a Cloudflare Tunnel; its origin IP isn't in public DNS.

Reason
The journal is the workload an outage or compromise would hurt most - a national academic institution's publication - so it gets the strictest exposure rule on the box: there is nothing to scan.
Trade-off
Availability now depends on the tunnel provider staying up, not just the host.
Revisit when
If a future workload needs inbound access no tunnel can provide.
Decision

Separate the collector from the public reader

A root-run collector on a timer gathers privileged data; the internet-facing dashboard process only ever reads one JSON snapshot it wrote.

Reason
The public status dashboard needs data that requires root to gather (Docker, journald, cscli, restic), but the process answering public requests has no reason to hold that privilege itself.
Trade-off
Status data is only as fresh as the last collector run, not live.
Revisit when
If the dashboard ever needs to trigger actions rather than just report state.
Decision

Document every public port

Exposure requires a written reason.

Reason
A production host should never rely on accidental or forgotten forwards.
Trade-off
Adds operational discipline to each service change.
Revisit when
Continuously, as services are added or retired.
Decision

Treat documentation as a control

Make operation recoverable by another competent person.

Reason
Solo knowledge is a continuity risk.
Trade-off
Runbooks must evolve with the machine.
Revisit when
Whenever the topology, incident history, or service inventory changes.
Decision

One owner per layer

Remove overlapping network authorities.

Reason
Multiple valid configurations created an invalid combined system.
Trade-off
Reduces tool flexibility in exchange for deterministic operation.
Revisit when
Only when a migration plan names the replacement owner explicitly.
05 / Evidence

Proof, source, and inspectable outcomes

06 / Implementation

How the decisions appear in the build

/etc/nftables.conf
01#!/usr/sbin/nft -f02# Default-drop. The journal has NO inbound rule (it rides the Cloudflare Tunnel).03# Exactly one firewall authority owns this box - no ufw, no firewalld fighting Docker.04flush ruleset05 06table inet filter {07  chain input {08    type filter hook input priority 0; policy drop;09 10    ct state invalid drop11    ct state established,related accept12    iif "lo" accept13    ip protocol icmp accept14 15    # Management surface is LAN-only by policy (SSH, Samba, Cockpit).16    ip saddr 192.168.0.0/16 tcp dport { 22, 445, 9090 } accept17 18    # SSH: key-only (PasswordAuthentication no in sshd_config).19    tcp dport 22 accept20 21    # The ONLY two WAN-forwarded services: TeamSpeak 6 voice - documented + justified.22    udp dport 9987  accept             # TS6 voice23    tcp dport 30033 accept             # TS6 file transfer24 25    counter comment "dropped-inbound" drop26  }27 28  chain forward { type filter hook forward priority 0; policy drop; }29  chain output  { type filter hook output  priority 0; policy accept; }30}
Representative / sanitized excerpt

Default-drop ruleset. The journal has no inbound rule - it rides the tunnel.

Debian 12DockerCloudflare TunnelsystemdSSH HardeningnginxDuckDNSDNS / SRVIncident Response
07 / Operations

The unhappy path is part of the design

01

Why did the internet disappear every few weeks, and why did earlier firewall fixes fail?

Two racing DHCP clients and a rogue lease repeatedly replaced the default route, while firewalld, ufw, iptables-persistent, and Docker each affected an overlapping layer - so a fix in one tool often wasn't the layer actually blocking traffic.

Signal
journald recorded route changes at the moment of failure; rules changed in one tool while another tool or runtime remained authoritative.
Response
Reduce to one authoritative DHCP client and one firewall authority, with Docker keeping its own chains. Zero recurrences since.
08 / Results

What the project demonstrates

Detection (CrowdSec), vulnerability management (Trivy), and infrastructure-as-code drift checks (Ansible) run against production, not just incident response

A public status dashboard exposes real container health, security posture, and backup history for anyone to verify directly

The highest-value workload has zero inbound ports and real production traffic — a national journal client, not a demo

Recurring connectivity incident stopped after ownership was simplified

Public and private services now have explicit exposure rationale

Operational knowledge is preserved in a living system handbook

What worked
  • Splitting collector privilege from the public reader means a compromised dashboard can read one file, not call sudo docker.
  • Diffing CVE IDs instead of alerting on raw totals caught a real CRITICAL same-day without drowning in noise.
  • All three original next steps — external observability, drift checks, recovery practice — shipped rather than staying aspirational.
  • Evidence replaced reboot-driven guesswork during the network incident, and layer ownership made the system predictable.
Next iteration
  • Execute the scoped OJS 3.5 upgrade to close the remaining CRITICAL CVEs
  • Install a CrowdSec bouncer so verified detections start blocking automatically, not just alerting
  • Extend the Ansible role beyond monitoring and backups as more of the host's configuration becomes code
Professional signal
  • Detection engineering
  • Vulnerability management
  • Infrastructure as code
  • Operational security
  • Incident analysis
  • Linux troubleshooting