When I decided to start writing again, I could have taken the easy route — a managed WordPress host, a Cloudflare account, done in an afternoon. Instead I spent a few weeks building something I actually understand from top to bottom. This post documents what I built, the decisions behind it, and what I learned along the way.
Full disclosure: I used Claude (Anthropic’s AI assistant) as a thinking partner throughout this project. Not to generate code blindly, but to work through architecture decisions, debug unexpected behavior, and validate my reasoning. I’ll mention where that collaboration mattered.
The Goal
A private tech blog with these constraints:
- Full control over the stack — no managed hosting, no black boxes
- Proper security — not security theatre, but real defence-in-depth
- DSGVO/GDPR compliance — I’m based in Austria and Germany, this is non-negotiable
- Low operational overhead — I have a day job
The result is gex.guru, running on infrastructure I own and operate, with a setup that would be unreasonable for most personal blogs but makes sense if you run a homelab anyway.
Architecture Overview
The high-level picture is straightforward: a dedicated root server running Proxmox VE as the hypervisor, with workloads isolated in LXC containers, protected by a hardened edge running OPNsense with Caddy as the reverse proxy and CrowdSec for threat detection.
Internet
│
▼
OPNsense (edge router + firewall)
│
├── Caddy (reverse proxy, TLS termination)
│ │
│ ├── WordPress LXC (gex.guru)
│ └── Static Hosting LXC (gex.at, gex.bayern, holzer.tirol)
│
└── CrowdSec LAPI (central threat intelligence)
│
├── Log processor: WordPress LXC
└── Log processor: OPNsense
Port 80 is blocked externally. All traffic arrives on 443, TLS terminates at Caddy, and backend communication is plain HTTP on the internal network. Simple, auditable, correct.
The Decisions That Mattered
Why Proxmox + LXC instead of ESXi
This question deserves an honest answer, especially given that I work as a Technical Account Manager for VMware by Broadcom.
My homelab runs VMware VCF stacks. I work with VMware technology every day and I believe in it for enterprise workloads. The blog infrastructure runs on Proxmox for two unrelated and entirely practical reasons.
Reason one: the hardware. Hetzner does offer dedicated servers with ESX — but they sit at a significant price premium. I use Hetzner’s Serverbörse, their server auction marketplace where older decommissioned hardware is available at a fraction of the cost. These are great machines for a personal blog, but they are not on the VMware Hardware Compatibility List for current ESX releases. Running Linux on them is fully supported and straightforward; running ESX is not the intended use case.
Reason two: the homelab hardware is aging. The physical machines in my homelab that run VCF are older generation. Proxmox runs happily on hardware where running a current VMware stack would require a hardware refresh I haven’t prioritised. For a blog, that’s the right trade-off.
Proxmox on a current Debian base, receiving regular security updates, is the right choice for this use case. LXC containers give me the isolation I need for separating workloads with significantly less overhead than full VMs — relevant when you’re on a single dedicated server and want to keep resources available for other projects. The WordPress stack and the static sites live in separate containers with separate network identities. If one gets compromised, it doesn’t automatically mean the other is.
Why OPNsense as the edge
OPNsense gives me a proper firewall with a real ruleset, DNS resolution I control, and a platform I can extend. Caddy runs as a plugin inside OPNsense rather than as a separate host — one fewer moving part, and the TLS termination and the firewall are operated by the same system. Certificate issuance uses DNS-01 challenges via HE.net, which means port 80 never needs to be open. That single decision eliminates a substantial class of attacks before they reach the application layer.
Why CrowdSec instead of Fail2ban
CrowdSec’s multi-agent architecture fits my setup better than Fail2ban would. The central LAPI (Local API) runs on OPNsense and holds the decision engine and the ban list. Each LXC runs a log processor that ships events to the LAPI without making local ban decisions. This means:
- A ban decision made from WordPress log analysis is enforced at the network edge, not just at the application
- The ban list is shared across all protected services
- Adding a new service means deploying a log processor and pointing it at the existing LAPI — no duplicated configuration
Getting this right required solving a subtle problem: Apache was logging the reverse proxy’s internal IP address instead of the real client IP. CrowdSec was effectively whitelisting every attack because they all appeared to come from a trusted LAN address. The fix was configuring Apache’s mod_remoteip module to trust the reverse proxy and extract the real client IP from the forwarded headers — and adjusting the log format to write the extracted IP rather than the connection IP. Without this fix the entire threat detection layer is silent.
This is the kind of problem that’s easy to miss and hard to diagnose. Working through it with Claude helped — not because the AI knew the answer, but because explaining the symptom clearly (CrowdSec banning nothing despite obvious scanning activity in the logs) led directly to the right question.
WordPress root path
The community-scripts installer places WordPress in /var/www/html/wordpress/ — not in /var/www/html/ directly. This matters more than it sounds: must-use plugins, custom configuration, and anything that references the WordPress root needs to use the correct path. Getting this wrong means your plugins load silently from a directory WordPress never reads.
I made this mistake. Every plugin I deployed for the first few hours did nothing, with no error, because it was sitting in the wrong directory. Lesson: always verify the actual WordPress root before writing a single line of configuration.
The WordPress Stack
Theme: GeneratePress. Fast, minimal, well-maintained, does what it says.
Must-use plugins (deployed as mu-plugins, loaded before regular plugins, not manageable from the admin UI):
- advocacy-publisher.php — enforces category assignment for posts published via the Firstup/Dynamic Signal XML-RPC integration. Without this, every advocacy post lands in the wrong category regardless of what the integration sends.
- theme-toggle.php — injects the dark mode init script into
<head>at priority 1 (before any stylesheet renders), adds the toggle button to the primary navigation, and handles localStorage persistence. Running this as an mu-plugin rather than in Additional CSS/JS ensures the theme is set before the browser renders anything, eliminating the flash of wrong theme on page load. - advocacy-template.php — parses Firstup-generated post content on render, extracts the original title, author attribution, and tracking URL, cleans redundant markup, sets the canonical URL to the original source, and disables comments on curated posts.
Plugins: Rank Math SEO (modules trimmed to the useful subset), Real Cookie Banner (banner disabled — no consent-requiring services are loaded), Two-Factor authentication, Integrate Umami, Email Protector.
Analytics: Self-hosted Umami in a separate LXC. Cookie-less by default, DSGVO-compliant without a consent banner, shared across gex.guru and the three static sites. No data leaves my infrastructure.
The Advocacy Integration
I participate in Broadcom’s Employee Advocacy program, which publishes curated industry content through a platform called Firstup (formerly Dynamic Signal). The integration publishes posts directly to WordPress.
A few things I discovered that aren’t documented clearly:
Firstup uses XML-RPC, not the WordPress REST API. The WordPress channel configuration takes a site URL (not an explicit /xmlrpc.php path), and the platform authenticates with an Application Password. This matters for two reasons: Application Passwords bypass two-factor authentication at the API level while keeping 2FA active for browser logins, and any plugin that disables XML-RPC entirely will silently break the integration.
The platform sends a default category that may not match your category structure. Without server-side enforcement, every post lands wherever WordPress decides. The mu-plugin intercepts post insertion and forces the correct category regardless of what the integration sends.
Firstup’s IP range hits your server with a distinctive pattern: it polls the RSS feed, then HEAD-checks existing post URLs, then publishes via XML-RPC, then polls the feed again to verify. This pattern looks like aggressive scanning to a naive threat detection system. Whitelisting the known integration IPs in CrowdSec prevents legitimate publishing traffic from triggering bans.
Dark Mode Without a Plugin
Dark mode on this site works without any plugin and sets no cookie.
The implementation uses CSS custom properties (--gex-bg, --gex-ink, --gex-blue, --gex-orange) defined for both light and dark themes on the html element via a data-theme attribute. A small init script in <head> reads localStorage for an explicit user preference, falls back to prefers-color-scheme, and sets the attribute before any stylesheet renders. The toggle button appended to the navigation writes back to localStorage on click.
The result: no flash of wrong theme, system preference respected by default, manual override persisted across sessions, and no cookie consent implications because localStorage is technically necessary for the feature and contains no personal data.
DSGVO / GDPR
Running a personal blog in Austria and Germany with a German employment contract means taking data protection seriously. The decisions that kept this clean:
- No external fonts (served locally or not used)
- No external analytics (self-hosted Umami)
- No social embed widgets
- No comment system with third-party tracking
- Cookie banner is deployed (Real Cookie Banner) but the banner itself is suppressed because there is nothing on the site that requires consent
localStoragefor dark mode preference: technically necessary for functionality, no consent required- Static sites: no external resources loaded at all, explicitly noted in the page comments
The Employer Disclaimer on every domain is intentional: content on these sites reflects my personal opinion and does not constitute statements of Broadcom Inc., VMware, or affiliated companies.
What I Would Do Differently
Set up WP-CLI from the start. Managing WordPress from the command line is faster and more scriptable than the admin UI for bulk operations. I did most of the setup through the UI and only reached for the database directly when I needed to.
Test the full request path before deploying threat detection. The mod_remoteip issue cost me time because I assumed the logging was correct. Verify that what your logs show matches what your monitoring acts on before you trust either.
Verify directory paths before writing configuration. This sounds obvious. It isn’t, when you’re working fast and the installer puts things where you don’t expect them.
On Using AI as a Build Partner
I want to be direct about this: Claude was involved throughout this build. Not as an autocomplete tool, but as a collaborator for working through decisions.
The places where it added genuine value:
- Architecture review — describing what I wanted to build and getting back specific questions about failure modes I hadn’t considered
- Debugging — the
mod_remoteipissue, the wrong WordPress path, the PHP fatal error in the advocacy template filter. In each case, showing the error and the context led to a correct diagnosis faster than I would have reached alone - Content — this post, the About page, the legal texts. I wrote the facts; Claude helped structure and phrase them
The places where you still need to think for yourself:
- Security decisions — an AI will suggest reasonable defaults, but understanding why a configuration is correct (or dangerous) requires your own judgment
- Your specific environment — the model doesn’t know your network topology, your threat model, or what you’ve already tried. You have to provide that context explicitly and completely
The outcome is a setup I understand fully and can maintain and extend without referring back to anything external. That was the goal.
What’s Next
The infrastructure is complete. What’s missing is original content — which is the actual point of having a blog.
Posts I’m planning to write:
- A deeper look at the CrowdSec multi-agent setup and the trusted proxy configuration
- Running mailcow in production: what I’ve learned after years of self-hosted email
- Home Assistant and Frigate as a unified system: architecture, not just installation
If any of those are useful to you, the RSS feed is at https://gex.guru/feed/.
Built with Proxmox, OPNsense, Caddy, CrowdSec, WordPress, and GeneratePress. Assisted by Claude (Anthropic). Opinions are my own.