How I created a self-hosted portfolio web server running Nginx, Ghost, and leveraging Cloudflare's Zero Trust tunnel with end-to-end encrypted HTTPS. This lab journal is the companion to another of my entries, Building a Zero Trust DMZ for a Self-Hosted Web Server. While that journal focused on the security of the web server, this entry will focus on the infrastructure that gets the site from my server to your screen.
Why Ghost?
As I mentioned in my journal post regarding the DMZ configuration, I am not a web developer. The self-hosted Content Management System (CMS) market offers a handful of options, with WordPress being the best-known. However, for me, Ghost was the ideal choice.
Ghost is a platform that is designed specifically for content publishing. The package ships with a clean admin interface, well-documented theme API, and a Node.js backend that is relatively straightforward to run on a Linux box. The most important function Ghost provides is the ability to fully control the HTML and CSS rendered in the browser. This is important because the site, with its custom SIEM dashboard, requires levels of customization that WordPress would have made difficult to implement.
A Note on AI-Assisted Development
Building this site involved heavy use of AI tools, specifically Claude, as a development and learning resource. I want to be upfront and transparent about its usage rather than try to obscure it. My background, again, is not in web development, and the custom SIEM-themed dashboard this site runs on required me to quickly work through an unfamiliar stack. AI accelerated that process.
I view the hands-on use of AI tooling as a skill set worth developing. With a large amount of work being offloaded to AI agents across every facet of the business world, the efficient use of AI tools will be a critical part of one's toolbox in the next few years. It is increasingly part of how security and infrastructure work gets done, and I hope to gain fluency with it on my own projects rather than encounter it for the first time in a professional context. It is a powerful but dangerous tool if used incorrectly.
The Stack
This section provides an overview of the full infrastructure stack from top to bottom:
- Cloudflare edge - public DNS, DDoS mitigation, TLS termination for the browser-facing connection
- Cloudflare Zero Trust Tunnel - encrypted outbound connection from the server to Cloudflare's edge, eliminating all inbound WAN port exposure
- Nginx - reverse proxy on the VM, receives traffic from the tunnel and forwards it to Ghost
- Ghost v6 - CMS running on Node.js, listening on localhost only
- Ubuntu 24.04 LTS - host OS on the VM
- Proxmox - hypervisor running the VM on dedicated home lab hardware
Each layer in this stack had a specific job. There is no overlap. While I am lucky to have access to a server in my lab with ample resources, I try to prioritize a slim build to avoid unnecessary resource overallocation that could limit me in future deployments.
VM Provisioning
The web server itself was provisioned by following the documented lab process; see the Provisioning a Hardened Linux VM on Proxmox write-up for the full baseline deployment. The VM sits on the DMZ network segment and was given a statically assigned address. By design, I am leveraging Cloudflare's public DNS resolver rather than the internal lab resolver, and the system was hardened before any applications were installed.
Installing Ghost
Ghost has a very well-supported installation path via the Ghost CLI. This path handles the Node.js process management, database configuration, and Nginx integration. The prerequisite applications: Node.js, Nginx, and MySQL, were all installed and verified before the CLI accessed anything.
By default, the Ghost CLI expects to be the owner of the domain configuration. As a result, it writes its own server block. I allowed it to do this for the initial HTTP configuration; however, I manually modified the Nginx config afterward to add SSL once the Cloudflare origin certificate was in place. Ghost listens locally on the VM and is not directly reachable from anything outside of the VM. All traffic MUST flow through Nginx.
One critical configuration step that is easily missed, I made sure to adjust was: after SSL was working end to end, Ghost's own URL configuration needed to be updated to reflect the secure https:// rather than the http://. Ghost uses this internally to generate absolute URLs for links, RSS feeds, and canonical tags. Running the command ghost config url https://chaselarocca.com and restarting Ghost after SSL is confirmed working ensures the CMS generates correct URLs rather than mixed references.
Nginx as Reverse Proxy
Nginx sits between the Cloudflare tunnel and Ghost. Its job is to receive incoming requests forwarded through the tunnel and proxy them to Ghost's localhost listener. The configuration handles two server blocks. It handles HTTP on port 80 (which is ultimately redirected to HTTPS). The second is HTTPS on port 443, where the actual proxying occurs.
The HTTPS block is where the origin certificate is referenced, the SSL protocols (TLSv1.2 and TLSv1.3) are specified, and the proxy headers are configured to pass the real client IP and forwarded protocol through Ghost. The client_max_body_size directive is set to 50 MB to accommodate image uploads via the Ghost admin panel.
Cloudflare Origin Certificate and Full Strict SSL
The traffic path from a browser to this server involves two encrypted connections. These include: the browser to Cloudflare's edge (verified by Cloudflare's standard certificate), and Cloudflare's edge to the origin server through the tunnel. Without an origin certificate, the second leg of the path is either unencrypted or using a self-signed certificate that Cloudflare can't validate.
I generated a Cloudflare Origin Certificate through the SSL/TLS dashboard. The certificate was issued specifically for this domain, valid for 15 years, and covers the root domain and wildcard subdomains. The certificate and private key are installed on the VM under Nginx's SSL directory with appropriate file permissions, and Nginx was configured to reference them in the HTTPS server block.
With the origin certificate in place, Cloudflare's SSL mode was set to Full(Strict). This explicitly tells Cloudflare to validate the origin certificate rather than simply encrypting without verification. Through this process, I can ensure a fully encrypted connection from Cloudflare's edge to the origin. There is no plaintext hop anywhere in the traffic path.
Key Takeaways
A self-hosted web server is a lot more than just a website. The choices regarding CMS, runtime, and reverse proxy all come with significant security considerations that are taken away when utilizing a managed hosting service.
Full Strict SSL ensures end-to-end encryption. Leveraging Cloudflare to handle HTTPS at the edge is not sufficient on its own. Without the origin certificate and Full Strict mode, the Cloudflare-to-origin leg is either unverified or unencrypted.
URL configuration is not just cosmetic. Setting the correct https:// URL in Ghost's config affects every internally generated link. It's a post-SSL step that's easy to skip and produces hard-to-diagnose mixed-content issues.
Layering a reverse proxy in front of the application is secure architecture. Nginx handles SSL termination, header injection, and request routing. Ghost handles content. Neither does the other's job.