How I designed and enforced a Zero Trust DMZ for a publicly exposed web server. This project covers topics from VLAN Isolation, OPNsense firewall rules scoped to specific IPs, a Cloudflare tunnel that eliminates all exposed WAN ports, and layered application-level access control on the Ghost admin panel.
Introduction
The desire to self-host my website came from numerous failed attempts to build a portfolio site the easy way using a drag-and-drop web designer. Every time I started, I hit a roadblock or limitation that ultimately led me to call the project a loss and focus my energy elsewhere. However, over the past few weeks, I have been focused on upgrading my online professional presence to put my best foot forward when my application is viewed by hiring managers. As a result, I began thinking about what a good portfolio website for me would look like. I wanted something that would demonstrate my knowledge of my chosen domain and provide recruiters with insight into me as a person. My home lab infrastructure is now at a point where the core functionality is in place and keeps data moving. The resources required to sustain core functionality have been allocated, and, for the time being, the excess can be used for projects that provide utility rather than solely sustaining infrastructure.
I am not a web developer. I am not a programmer. My functional understanding of building a website consists of making an HTML and CSS file and linking them together, but in the past I have never hosted anything that made it farther than localhost:5000. Through previous projects, I had learned the pitfalls of operating a home lab on an internet plan that did not have any static IP addresses allocated to it. I knew that simply opening up my network via port forwarding to my web server on ports 80 and 443 would create significant overhead as my public IP kept changing. In the past, I had leveraged Cloudflare's Dynamic DNS tooling. This system leveraged the cloudflare library on my OPNsense router to automatically pass public IP information to Cloudflare. See the Minecraft Server Setup project entry for more information on the lessons learned surrounding Dynamic DNS.
Ultimately, the problem with exposed ports is that they invite trouble. An open port on a WAN interface adds to your attack surface and can be exploited by an attacker. Port scanners don't care about your intentions. They will ultimately find your website, probe it, and catalog it. This is something I have experienced firsthand in my professional life. For this reason, we must layer in redundant systems to, at worst, serve as the sole protector of our data security and, at best, act as an additional sieve to filter traffic before it even reaches the inner walls.
The Cloudflare Tunnel
The tool that makes this entire setup possible is the Cloudflare Zero Trust tunnel. Rather than having my server sit behind a port forward waiting for inbound connections, the cloudflared daemon running on the web server initiates an outbound connection to Cloudflare's edge network. When someone visits chaselarocca.com, the request first hits Cloudflare's infrastructure. Cloudflare then routes the traffic inbound through the tunnel to my server. From my network's perspective, the only connection that exists is one my server opened. Nothing from the outside world is knocking on my door.
This solution also conveniently solves my static IP address problem. I only have so much money to dedicate to my home lab, and I try to spend it where it counts. An additional monthly charge from my ISP just for a static IP address was not going to make the budget. Fortunately, the tunnel solves this problem in an elegant (and free) way. With the old DDNS setup on OPNsense, the primary goal was to ensure that a DNS record was always pointed to a specific IP address. The tunnel removes my public IP from the equation altogether. Cloudflare doesn't need to know anything about me. The server is calling outbound to Cloudflare, and that connection stays open. My IP could rotate every minute, and the website would never falter.
DMZ VLAN Design
With the WAN exposure problem resolved, the next question was where the server lived on my internal network. Beyond its place of residence resided the question of who it was allowed to talk to in this new neighborhood. My somewhat authoritarian answer was: as little as was possible.
The web server lives on a dedicated VLAN. This VLAN is designated as the DMZ. A DMZ is a common enough term in server architecture and anyone familiar with the history of one of the most interesting conflicts of the 20th century.... or KPOP will know what a DMZ is. This segment of the network has no trust relationship with any other VLAN in the lab by default. The only exception to the rules is what is needed for my management host to access the server to perform the necessary configuration tasks.
Configuring a new VLAN on the network was a fun refresher on the countless complications that come with trying to integrate something new into a structure that has had time to settle. My first step was with my switch. The VLANs took like first on the switch almost a year ago now. In that time, I have added very little beyond what was created initially. Adding the DMZ VLAN required accessing the switch and creating a new VLAN. I needed to add tags to the ports for the ProLiant and my primary internet uplink to allow tagged traffic with the new VLAN ID.
The switch is only half the battle. As we will discuss next, the next step is inside OPNsense. The new VLAN needed to be registered in OPNsense, and then we needed to create an interface that reflected the address space we wanted for this new VLAN. Using the naming standard, we created a new interface. Interfaces are required to register DHCP. This portion of the configuration is something I will be revisiting, as a potential control I imagined was limiting the DHCP space to a very small number of devices, or to 0, and allocating static IPs only to known hosts. This would limit the capabilities of someone managing to create a new device inside the DMZ. The interface is also required, as firewall rules must be associated with it. The interface matches the VLAN traffic, and the firewall rules determine what that traffic is allowed to do.
One decision worth calling out explicitly: the DMZ VM uses Cloudflare's public DNS resolver (1.1.1.1) rather than my internal AdGuard instance. A public-facing server has no business resolving internal hostnames. If the server were ever compromised and an attacker tried to enumerate what else is on my network via DNS, they would get nothing. This was an intentional design decision. DNS is a security boundary, and the DMZ sits outside of it.
OPNSense Firewall: Scoped to IPs, not Subnets
As with most VLANs in my environment, the firewall rules governing the DMZ are designed around a default-deny model. Nothing is inherently permitted unless explicitly permitted by me. For this reason, most of the tuning during configuration takes the form of exceptions.
The start to any project in the home lab follows an almost formulaic approach. As is covered many times in many different ways in many different posts, the project begins with the spawn of a new VM on my HP ProLiant running Proxmox. The machine is created, named, and tagged using the new standard. However, this one had more complications than the previous VMs. As mentioned, this deployment necessitated the creation of a new VLAN. As a result, I initially ran into issues when trying to assign the new VM a static IP address. I recognized that I had tried to assign a static IP address that fell within the DHCP range for the DMZ interface in OPNsense. This resulted in the VM failing to negotiate an IP lease and to gain internet access. This, however, was only half the issue. Upon remediating the first problem, I realized I had been using my internal DNS server for the VM, which was the method I had used for all my recent VM deployments outside the DMZ. This caused me to reflect upon the DNS server the VM was ultimately using, as previously discussed. I ended up changing this DNS server to Cloudflare's public DNS. This ultimately resolved my connectivity issue.
Once the VM itself was configured, we moved on to ensuring the management subnet had the access it needed, the VM could access the resources it required, and it was prohibited from accessing anything outside of its scope. To achieve this, we took a very granular approach to the rules. Rather than allowing based on subnet, we scoped specifically to IP addresses. The management host isn't the only thing that lives on the management subnet, but it is the only thing that should expand beyond its subnet to the DMZ. For this reason, the rules were scoped exclusively to SSH functionality from the known management host IP address. Every other source is denied inbound beyond that.
In terms of outbound rules, the DMZ explicitly blocks the address spaces associated with my LAN subnets. The server is permitted to reach Cloudflare's infrastructure over ports 443 and 80 and resolve DNS specifically to 1.1.1.1 on port 53. From a firewall perspective, there is functionally no way for a server to reach back to the lab, thus thwarting any attempts at lateral movement.
A Second Layer at the Host Level
As previously mentioned, I thoroughly support the concept of layering defenses. So, even with strict OPNsense rules and no exposed WAN ports, I run UFW on the server itself. The network firewall is not the last layer in my defense-in-depth strategy. If, by some chance, a misconfiguration on the firewall opened the DMZ, the UFW would act as a second enforcement point on the host, operating independently of anything at the network level.
UFW is configured to deny all inbound traffic by default. The only explicit allow rule is SSH on port 22, which is restricted to the management host. The Cloudflare tunnel requires no inbound firewall rules. This is purely an outbound connection the server initiates. Ghost listens and is reachable only through Nginx, which is reachable only through the tunnel. There is no path from the internet to any port on this server that does not go through Cloudflare first.
Cloudflare Access & the Zero Trust Admin Panel
I am utilizing Ghost as the container hosting the website. Ghost ships with an admin panel. It goes without saying this is an exceptionally critical location within the website's architecture. Fortunately, this was a prime opportunity to put at least one of the controls frequently outlined in Zero Trust architecture into practice. That is an identity authority placed between a sensitive site and the user.
For this purpose, I configured a Cloudflare Access application policy scoped specifically to the admin URL path. Before the login form is ever rendered, Access intercepts the request and requires the visitor to authenticate via a Cloudflare-managed one-time password sent to an authorized email address. Only one email is in the list. An unauthenticated request never reaches my server. Cloudflare stops it at the edge.
This is fundamentally what Zero Trust looks like when applied to an admin surface. The panel does not implicitly trust anything on the network. It requires some form of identity verification before the session ever begins.
Key Takeaways
No inbound ports at WAN. The Cloudflare tunnel means there is nothing listening on my WAN interface for this server. There is no threat of random scanners probing my network because of the website.
Scope firewall rules to IPs, not subnets. Subnet-level rules are a trade-off. They trade precision for convenience. Sometimes that might be necessary, if you are spanning vast infrastructure and your access controls are managed more effectively at a different level. However, specific source and destination IPs enforce least privilege more tightly and produce a ruleset that is incredibly easy to audit.
Local IP ranges blocked on the DMZ are essential. A compromised DMZ host should not be able to reach internal networks. An explicit block rule enforces this practice regardless of potentially conflicting rules below it.
An application's management panel is a prime target. The admin panel required for Ghost's functionality is immediately a massive target for anyone looking to harm the website. It is a prime candidate for the Zero Trust model.