Homelab — Home Server + OCI IaC
A solo personal infrastructure designed to satisfy zero inbound ports, VPN-only management, and code-defined infrastructure simultaneously
Tech Stack
Overview
A solo personal infrastructure built around a Proxmox VE hypervisor running at home, hosting pfSense (router/firewall) alongside service containers for the portfolio, blog, and other personal services. On the cloud side, a Terraform-managed OCI auxiliary node runs Ansible Semaphore as the control plane that manages the home server’s configuration remotely. The home server holds the publicly exposed services; the OCI node manages that server via code.
Operating this setup, the goal was to satisfy three constraints simultaneously: zero inbound ports, VPN-only management, and infrastructure defined entirely in code. Instead of the common trade-offs — sacrificing automation for security, or relaxing determinism for automation — every design decision is justified against all three axes so they don’t end up cannibalizing each other.
Tech Stack
- Home server: Proxmox VE, pfSense, Cloudflare Tunnel (
cloudflared) - Cloud: OCI (VM.Standard.A1.Flex, 4 OCPU / 24GB ARM), Terraform 6.x with
oracle/oci, S3-compatible state backend - Automation & connectivity: Ansible Semaphore, WireGuard, Docker, GitHub Actions, Watchtower
My Role
Solo across design and operations. I made the security-model decisions, structured the OCI Terraform modules, integrated Semaphore with WireGuard, wrote the GitHub Actions pipeline, and folded operational issues like race conditions and ordering bugs directly back into the code and workflows.
Key Contributions
-
Pinned “zero inbound ports” as a single operating principle.
- Public services go through outbound-only Cloudflare Tunnel sessions, the OCI subnet’s ingress rules are empty, and CI/CD has the home server polling GHCR — public exposure, management plane, and deployment all derive from the same principle. The trade-off (more dependence on outbound connectivity for less attack surface) is acceptable at this scale; retrospective on my blog.
-
Restricted OCI access to a stateful firewall + a single WireGuard UDP session.
- Even with no inbound rules, stateful tracking lets return traffic through after the handshake, and a 25-second keep-alive keeps the NAT session from timing out. I run WireGuard directly instead of an external mesh SaaS like Tailscale because of one principle: the control plane should not live in someone else’s cloud.
-
The regenerable definition is the source of truth — first-boot ordering guards encoded in the boot script.
- SSH stays closed; on failure I rebuild the VM via Terraform, with OCI Serial Console + a first-boot-set password as a deliberate emergency backdoor. Because this model assumes “the same result on every rebuild,” I encoded the ordering guards for first-boot non-determinism directly into the boot script: (1) a retry helper (up to 60 attempts at 10-second intervals) absorbing package-manager lock contention from the system’s automatic updates, and (2) a 60-second polling loop waiting for the WireGuard interface to actually have its IP before Docker binds to it. Encoded in the script — not patched once and forgotten — so determinism lives in the definition, and rebuilds boot identically every time.
Troubleshooting
- Docker fails to bind because the WireGuard interface hasn’t been assigned an IP yet — an ordering race.
- Problem: The command that starts the WireGuard service returns immediately, but the kernel actually assigns the IP a few milliseconds later. The Docker step that follows tries to bind to that address and fails with “cannot assign requested address.” A naive
sleepdoesn’t help — IP-assignment timing varies by host, and determinism needed to be encoded in code, not guessed. - Solution: After starting WireGuard, the script polls the interface up to 30 times at 2-second intervals and only proceeds to Docker once the IP is actually assigned. Encoded as an ordering guard rather than a one-off patch, so rebuilds boot reliably every time.
- Problem: The command that starts the WireGuard service returns immediately, but the kernel actually assigns the IP a few milliseconds later. The Docker step that follows tries to bind to that address and fails with “cannot assign requested address.” A naive
Impact
A setup where security, automation, and determinism balance against each other inside a single repository instead of stealing budget from one another. The most satisfying part wasn’t shipping any individual piece — it was that operational incidents (the wg0 race, ordering bugs) got folded back into user_data.sh and the workflows, so the same decisions don’t have to be made twice.