Infrastructure·Personal reference environment

Secure self-hosted infrastructure

A self-hosted environment designed around a small public surface, certificate-gated management access, explicit routing, private service discovery, and documented operating procedures.

ProxmoxOpenVPNPKIPrometheusLokiGrafanaLinux
Type
Self-hosted personal & development platform
Host
Single Proxmox virtualization host
Access
OpenVPN mutual TLS · routed · split-tunnel
Observability
Prometheus · Loki · Grafana

Most homelabs get their security posture by accident. Someone stands up a NAS, then a wiki, then a monitoring stack, and each one quietly grows its own login page and its own bit of internet exposure until nobody can name the actual attack surface anymore. I wanted to build the opposite of that on purpose: one thing on the internet, everything else reachable only after that one thing has authenticated you. This isn’t an enterprise architecture and I’m not going to pretend it is. It’s a single Proxmox host running real infrastructure I actually depend on, source control, container registry, internal apps, monitoring, PKI, and the interesting part isn’t the service list, it’s the handful of decisions that determine what a compromise of any one piece actually costs.

The only thing worth exposing is the door, not the rooms

Public networkOpenVPN endpointmutual TLSVPN client networkrouted accessINTERNAL SERVICE NETWORKApplicationservicesSource control& registryMonitoringStorageCertificatemanagement host

The governing rule here is simple to state and easy to violate in practice: the public network should see exactly one access mechanism, and every internal service should live behind it rather than growing its own public-facing login and TLS config. In this environment that’s an OpenVPN endpoint using mutual TLS. Nothing else, no dashboard, no source-control UI, no registry, no monitoring stack, gets a public IP or a public hostname pointed at it. That collapses “how many things can an internet attacker directly poke at” down to one, and it means I’m not maintaining N separate hardening postures for N separate services, I’m maintaining one, thoroughly.

The obvious cost is concentration. That one endpoint is now load-bearing for all remote access, and if its certificate handling or server configuration goes wrong, the blast radius is “network reachability to everything,” not “one service.” I don’t think that trade is avoidable if you actually want a small public surface. You can distribute the risk or you can shrink the surface, and shrinking it means something has to hold the concentrated responsibility. The way to make that acceptable is to take the one thing seriously: real certificate lifecycle management, real revocation, tight server exposure, and the assumption that VPN entry is necessary but never sufficient on its own.

Certificates over passwords, because revocation is the feature that actually matters

VPN access requires a client certificate signed by an operational intermediate CA, not a shared PSK or password. The identity story that buys you is the whole point: individual revocation per device, real expiry instead of a password that lives forever in someone’s password manager, separate profiles for servers versus clients, and an actual lifecycle you can audit instead of a secret you hope nobody’s shared. The overhead is real too, certificates have to be issued, tracked, renewed, and revoked reliably, and a lost laptop or a departing collaborator turns into a revocation workflow rather than a five-minute password rotation. I’d take that trade every time. A shared secret is a single point of failure that doesn’t even tell you it failed. A certificate you can revoke the moment you know a device is gone is a control, not a hope.

This is also why the VPN and the certificate daemon behind it aren’t really two separate projects, they’re one system with two halves. Network design without a credible certificate lifecycle is just a fancier password. A certificate lifecycle with no coherent network design in front of it doesn’t buy you much either. They have to be reasoned about together.

Routed access instead of NAT, because I want to know who’s actually knocking

VPN clients get a real route into the internal network rather than appearing to every service as one anonymized gateway address. That’s a deliberate choice against the more common NAT-everything default, and the payoff is attribution: internal services see the actual client’s tunnel address, which makes firewall rules readable and log entries mean something instead of every internal connection looking like it came from the same box. The tax for that clarity is that every internal system has to know how to route a response back to the VPN client network, and a missing return route produces the most annoying failure mode in networking: the request arrives fine, the response leaves through the wrong door, and you spend twenty minutes convinced the firewall is broken when it’s actually a routing table. I’ve stopped treating that return-route requirement as tribal knowledge. It’s now a checklist item every new machine goes through before I trust it’s actually reachable, because undocumented routing assumptions are exactly the kind of thing that works fine for six months and then breaks in a way nobody remembers how to debug.

Split tunneling, on purpose, not as a shortcut

General internet traffic never goes through the tunnel, only traffic bound for the internal network does. That’s not laziness, it’s a match to the actual threat model: this VPN exists to reach private services, not to anonymize browsing or centralize egress inspection, and pretending otherwise would mean running a full internet gateway I don’t need and can’t meaningfully inspect at this scale anyway. The honest downside is that a compromised client keeps its normal internet access at the same time it has internal reachability, and DNS behavior has to be designed carefully or you get leakage, queries for internal names resolving the wrong way or public names resolving through the wrong resolver. I’ve made peace with that trade-off because the alternative, full-tunnel everything, would buy inspection capability I don’t actually use in exchange for cost and complexity I’d be carrying for no real benefit.

Split DNS, so internal names stay internal without becoming a second source of truth

Internal services get real hostnames that only resolve to their real addresses when you’re on the VPN, resolved by a private DNS layer clients pick up automatically once connected. Off the VPN, those names either don’t resolve or resolve to something else entirely. The advantage is that I get to use normal HTTPS hostnames for everything internal instead of memorizing addresses or exposing private network layout in public DNS records. The honest cost is that DNS becomes part of the access path, not just a convenience: if the resolver’s down, routing might still technically work by address, but nothing is reachable by name, and that failure mode is easy to misdiagnose as “the service is down” when it’s actually “you can’t find it.” I treat DNS availability as a dependency of the access boundary now, not an afterthought sitting outside it.

The CA key never has to trust the thing people click around in

The certificate authority backing all of this isn’t embedded in whatever admin interface operators actually use day to day. Signing lives behind a dedicated daemon reachable only over a local Unix socket, with the true root kept fully offline and only ever invoked to establish or replace the operational intermediate. That separation means a compromise of an operator-facing web interface doesn’t hand an attacker the intermediate key directly, they’d still have to cross the daemon boundary or the host itself. I want to be honest about the actual limit here rather than oversell it: the daemon and the VPN gateway still live within the same broader host environment, so this buys real containment against an application-level compromise, and it buys essentially nothing against someone who’s already got the hypervisor. Different threats, different boundary, and pretending one architecture defeats both would be the kind of overclaiming I try hard to avoid in my own writeups.

Not every internal machine deserves the same trust

Machines in this environment split into two categories, and I think this distinction is underrated in homelab writeups generally. Internal-only machines sit on the private network exclusively, smaller footprint, simpler routing, nothing to reason about beyond “can VPN clients and other internal services reach it.” Dual-connected machines, the ones that genuinely need both direct internet access and internal reachability, get two interfaces, with the external one owning the default route and the internal one carrying only explicit routes for the private network. That routing rule is load-bearing: give the internal interface its own default route too and you get ambiguous egress, asymmetric routing, and DNS confusion that’s genuinely painful to debug. More importantly, a dual-connected box is structurally higher risk, it’s the one machine class that could actually bridge an external compromise into the internal network, and I treat it with more scrutiny than anything internal-only, not because I distrust the workload specifically, but because the topology itself deserves the extra suspicion.

VPN-authorized is not the same sentence as authorized

I try to be precise about what crossing the VPN boundary actually grants, because it’s tempting to treat “on the VPN” as a synonym for “trusted,” and that’s a mistake. A valid certificate gets you mutual TLS success and a route into the internal network. It does not get you permission to administer source control, read monitoring data, manage storage, or touch the certificate authority itself, those are separate, service-level authorization decisions that happen after network entry, not instead of it.

authorityOffline root CAoffline · invoked only to replace the intermediateCertificate daemonthe only component that signsInfrastructure & certificate operatorsadminister, per roleApplication userservice-specific permissionsservice-level authorization boundaryVPN-authorized clientafter mutual TLS · network reachability only, not authorityPublic attackerno access

The trust model, if I stack it honestly, runs from an unauthenticated public attacker, to a VPN-authorized client with reachability but no default authority, to an application user with service-specific permissions, up through infrastructure operators, certificate operators, the daemon that actually signs things, and the offline root at the very top. A compromised VPN client is not equivalent to a trusted administrator, and the whole design leans on keeping that distinction real through service-level auth rather than letting network access quietly become the only gate that matters.

Monitoring is visibility, not a security boundary, and it deserves its own caution

Metrics and logs from every internal system land in a central stack, one place for dashboards, one place for alerting. That centralization is exactly what makes it valuable and exactly what makes it sensitive: the same system that tells me a service restarted also knows hostnames, topology, container images, and log contents, which means it belongs on the private network like everything else, not treated as a harmless public dashboard. I’d also push back on a temptation I’ve felt myself, mistaking “we have dashboards” for “we have incident response.” Metrics only matter if the alerts are meaningful, someone actually receives them, evidence survives long enough to be useful, and a compromised service can’t quietly edit its own history on the way out. Right now that evidence still lives on-host, which is an honest limitation I’d rather name than gloss over: shipping security-relevant events somewhere the compromised system can’t reach is future work, not a solved problem.

Documentation is a security control when the alternative is memory

Every new machine goes through the same onboarding sequence: network attachment, address allocation, required routes, DNS registration, a connectivity check, monitoring agent install, registration with the metrics stack, and an explicit call on whether it’s internal-only or dual-connected. That’s not busywork, it’s the thing standing between this environment and the slow accumulation of undocumented exceptions that eventually becomes impossible to reason about, a missing return route here, a duplicate address there, a machine nobody remembers is dual-homed. It’s still manual, which means it still depends on a human actually following it, and a proper configuration-as-code model that makes the intended state enforceable rather than just documented is the honest next step. For where this environment actually sits right now, a procedure that’s followed beats an ideal that isn’t built yet.

What this setup is actually for, stated plainly

This is a single physical host, which means a hardware or hypervisor failure is the largest failure domain in the whole design, full stop, no amount of VM separation changes that math. VPN clients can reach a fairly broad slice of the internal network once connected, so the design leans on service-level authorization rather than heavy microsegmentation, a deliberate scope choice for a small number of operators, not an oversight. Off-box backups with actually measured restore times, tighter east-west segmentation, and off-host security logging are named as real gaps rather than hidden, because I’d rather a reader know exactly where the edges of this thing are than assume it’s more finished than it is.

TL;DR

The whole design rests on one idea: collapse the public attack surface to a single, seriously-hardened entry point, and then refuse to let crossing that entry point count as full authorization for anything past it. Certificates instead of passwords because revocation is the control that actually matters. Routed access instead of NAT because attribution beats convenience. Split tunneling and split DNS because the VPN’s job is reaching private services, not becoming a second internet. None of this makes the environment invulnerable, a hypervisor compromise still ends the conversation, and I’d rather say that plainly than let the architecture imply otherwise. What it does do is make sure a compromise of any single piece costs exactly what that piece is worth, and nothing more than that.

← back to work