<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Infrastructure | Derek Armstrong — Software Engineer · AI · Infrastructure</title><link>https://derekarmstrong.dev/categories/infrastructure/</link><atom:link href="https://derekarmstrong.dev/categories/infrastructure/index.xml" rel="self" type="application/rss+xml"/><description>Infrastructure</description><generator>Hugo Blox Builder (https://hugoblox.com)</generator><language>en-us</language><lastBuildDate>Sun, 24 May 2026 00:00:00 +0000</lastBuildDate><image><url>https://derekarmstrong.dev/media/sharing.png</url><title>Infrastructure</title><link>https://derekarmstrong.dev/categories/infrastructure/</link></image><item><title>Locking Down a DMZ Web Server with Cloudflare Tunnels</title><link>https://derekarmstrong.dev/blog/locking-down-a-dmz-web-server/</link><pubDate>Sun, 24 May 2026 00:00:00 +0000</pubDate><guid>https://derekarmstrong.dev/blog/locking-down-a-dmz-web-server/</guid><description>&lt;p&gt;You&amp;rsquo;ve got an edge web server. It&amp;rsquo;s internet-facing. In my case it&amp;rsquo;s a Rocky Linux VM sitting on its own VLAN, isolated from the rest of the homelab network. The only reason it&amp;rsquo;s exposed is Cloudflare Tunnels — traffic enters through Cloudflare&amp;rsquo;s edge, traverses the tunnel via &lt;code&gt;cloudflared&lt;/code&gt;, and hits services on localhost. No open ports on the router for anything but SSH.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s the ideal. Here&amp;rsquo;s what you actually need to do beyond &amp;ldquo;put it in a DMZ and run a tunnel.&amp;rdquo;&lt;/p&gt;
&lt;h2 id="network-segmentation-first"&gt;Network Segmentation First&lt;/h2&gt;
&lt;p&gt;The machine lives on its own subnet. In my setup that&amp;rsquo;s &lt;code&gt;192.168.5.0/24&lt;/code&gt;, with the gateway at &lt;code&gt;.5.1&lt;/code&gt;. No routes to the internal LAN (&lt;code&gt;192.168.1.0/24&lt;/code&gt;). Wi-Fi interface down — no accidental bridge if the box has a wireless NIC. DNS resolves through the DMZ gateway only.&lt;/p&gt;
&lt;p&gt;If your router doesn&amp;rsquo;t already support VLANs, do this before anything else. pfSense, OPNsense, Untangle — pick one and get it between your modem and your LAN. The DMZ VLAN needs its own interface, its own subnet, and no upstream routes to your internal network. Your router&amp;rsquo;s firewall should only allow the &lt;code&gt;.5.x&lt;/code&gt; subnet outbound to the internet. Not &lt;code&gt;.1.x&lt;/code&gt;. Not &lt;code&gt;.3.x&lt;/code&gt;. Not anywhere else.&lt;/p&gt;
&lt;p&gt;Verify it works: ping an internal IP from the DMZ host. If it replies, you missed a rule.&lt;/p&gt;
&lt;h2 id="host-firewall--only-ssh"&gt;Host Firewall — Only SSH&lt;/h2&gt;
&lt;p&gt;Every port except 22 goes through Cloudflare. The firewall reflects that. On RHEL-family systems, that&amp;rsquo;s &lt;code&gt;firewalld&lt;/code&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Remove everything that shouldn&amp;#39;t face the internet&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;sudo firewall-cmd --remove-service&lt;span class="o"&gt;=&lt;/span&gt;cockpit --permanent
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;sudo firewall-cmd --remove-port&lt;span class="o"&gt;=&lt;/span&gt;8080/tcp --permanent
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;sudo firewall-cmd --remove-service&lt;span class="o"&gt;=&lt;/span&gt;dhcpv6-client --permanent
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;sudo firewall-cmd --reload
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Verify&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;sudo firewall-cmd --list-all
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The output should show only &lt;code&gt;ssh&lt;/code&gt; in services. No ports. The default zone is &lt;code&gt;public&lt;/code&gt;, policy is reject by default. If you&amp;rsquo;re on Arch or Debian, use &lt;code&gt;nftables&lt;/code&gt; or &lt;code&gt;iptables&lt;/code&gt; — same concept, only port 22 inbound.&lt;/p&gt;
&lt;p&gt;Cockpit is the danger here. It ships pre-installed on many RHEL distributions and it&amp;rsquo;s a full web-based admin console. If it&amp;rsquo;s in the firewall allow list, someone can browse &lt;code&gt;https://your-ip:9090&lt;/code&gt; and get a graphical interface to your server. Remove it from the firewall minimum, disable the service if you don&amp;rsquo;t use it.&lt;/p&gt;
&lt;h2 id="service-minimization"&gt;Service Minimization&lt;/h2&gt;
&lt;p&gt;Audit what&amp;rsquo;s running. Everything on the box should have a reason to be there.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;systemctl list-units --type&lt;span class="o"&gt;=&lt;/span&gt;service --state&lt;span class="o"&gt;=&lt;/span&gt;running
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;On a web server, the normal set is: &lt;code&gt;sshd&lt;/code&gt;, &lt;code&gt;cloudflared&lt;/code&gt;, the container runtime, &lt;code&gt;firewalld&lt;/code&gt;, &lt;code&gt;chronyd&lt;/code&gt;, &lt;code&gt;NetworkManager&lt;/code&gt;, &lt;code&gt;rsyslog&lt;/code&gt;, &lt;code&gt;auditd&lt;/code&gt;. Everything else is a candidate for removal. Unnecessary services I&amp;rsquo;ve seen sitting idle: &lt;code&gt;atd&lt;/code&gt;, &lt;code&gt;cockpit&lt;/code&gt;, &lt;code&gt;systemd-journal-gatewayd&lt;/code&gt;, USB daemons, Bluetooth, and printers. Disable what you don&amp;rsquo;t need:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;sudo systemctl disable --now atd cockpit.socket cockpit.service
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h2 id="ssh-hardening"&gt;SSH Hardening&lt;/h2&gt;
&lt;p&gt;SSH is the only direct access point. Lock it down before someone tells you they should.&lt;/p&gt;
&lt;p&gt;Edit &lt;code&gt;/etc/ssh/sshd_config&lt;/code&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;PermitRootLogin no
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;PasswordAuthentication no
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;AllowUsers derek wordpress
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;MaxAuthTries 3
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That&amp;rsquo;s four lines. Most of your SSH exposure disappears with them.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;PermitRootLogin no&lt;/code&gt; — root can&amp;rsquo;t SSH at all, keys or not. Admin tasks happen under a regular user with &lt;code&gt;sudo&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;PasswordAuthentication no&lt;/code&gt; — only key-based authentication. Brute force is useless without keys.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;AllowUsers&lt;/code&gt; — explicitly list every user that should authenticate. By default any user in &lt;code&gt;/etc/passwd&lt;/code&gt; can SSH. That includes service accounts you created months ago and forgot about.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;MaxAuthTries 3&lt;/code&gt; — limits key attempts per connection. Matters less without passwords, but it&amp;rsquo;s noise reduction.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Validate the config before you reload:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;sudo sshd -t
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;sudo systemctl reload sshd
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Validate first. &lt;code&gt;sshd -t&lt;/code&gt; exits silently on success, prints an error on failure. Don&amp;rsquo;t skip it — a bad config reloads and you lose your active session.&lt;/p&gt;
&lt;h2 id="user-accounts"&gt;User Accounts&lt;/h2&gt;
&lt;p&gt;Check &lt;code&gt;/etc/passwd&lt;/code&gt; for interactive shells on service accounts:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;grep -v nologin /etc/passwd &lt;span class="p"&gt;|&lt;/span&gt; grep -v root
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Any account that corresponds to an application, a container user, or a backup job shouldn&amp;rsquo;t have &lt;code&gt;/bin/bash&lt;/code&gt;. Fix it:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;sudo chsh -s /sbin/nologin &amp;lt;serviceuser&amp;gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h2 id="selinux"&gt;SELinux&lt;/h2&gt;
&lt;p&gt;On RHEL-family systems, check. It should be &lt;code&gt;Enforcing&lt;/code&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;getenforce
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;If it&amp;rsquo;s &lt;code&gt;Permissive&lt;/code&gt; or &lt;code&gt;Disabled&lt;/code&gt;, something went wrong on install or someone disabled it because &amp;ldquo;it was blocking things.&amp;rdquo; You can tune SELinux around whatever was blocked. Disable it, you&amp;rsquo;re running your own custom firewall with no audit trail and no mandatory access control. Not worth it.&lt;/p&gt;
&lt;h2 id="fail2ban"&gt;fail2ban&lt;/h2&gt;
&lt;p&gt;SSH lives on port 22, internet-scanning bots will hit it every few minutes. SSH with password auth off doesn&amp;rsquo;t mean there&amp;rsquo;s no risk — malformed packets, protocol fuzzing, or anyone who somehow gets hold of a key still triggers auth attempts. fail2ban adds rate limiting at the connection level:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;sudo dnf install -y epel-release fail2ban
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Create &lt;code&gt;/etc/fail2ban/jail.local&lt;/code&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-ini" data-lang="ini"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;[DEFAULT]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="na"&gt;banaction&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;firewallcmd-rich-rules&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="na"&gt;bantime&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;3600&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="na"&gt;findtime&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;600&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="na"&gt;maxretry&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;3&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;[sshd]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="na"&gt;enabled&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;true&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="na"&gt;port&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;ssh&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="na"&gt;filter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;sshd&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="na"&gt;logpath&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;/var/log/secure&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="na"&gt;maxretry&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;3&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Start it:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;sudo systemctl &lt;span class="nb"&gt;enable&lt;/span&gt; fail2ban
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;sudo systemctl start fail2ban
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;fail2ban-client status sshd
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;If you&amp;rsquo;re on Debian/Ubuntu, the action is &lt;code&gt;iptables-multiport&lt;/code&gt; or &lt;code&gt;nf-iptables-ipset-proto6&lt;/code&gt;. If you&amp;rsquo;re on Arch with &lt;code&gt;nftables&lt;/code&gt;, use &lt;code&gt;nftables-multiport&lt;/code&gt;. The jail config itself is the same.&lt;/p&gt;
&lt;p&gt;On a production box where you SSH from multiple machines, the &lt;code&gt;ignoreip&lt;/code&gt; directive matters. Whitelist your workstations or management subnets:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-ini" data-lang="ini"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="na"&gt;ignoreip&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;127.0.0.1/8 ::1 192.168.1.0/24 192.168.2.0/24&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Read that list before you save the file. If your workstation IP isn&amp;rsquo;t on it and you misconfigure something, you&amp;rsquo;ll be locked out of your own machine.&lt;/p&gt;
&lt;h2 id="cloudflare-tunnel-as-the-only-inbound-path"&gt;Cloudflare Tunnel as the Only Inbound Path&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;cloudflared&lt;/code&gt; listens on localhost. Its admin port needs to bind to &lt;code&gt;127.0.0.1&lt;/code&gt; only. Check this with:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ss -tlnp &lt;span class="p"&gt;|&lt;/span&gt; grep cloudflared
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;If you see &lt;code&gt;0.0.0.0:20241&lt;/code&gt; or &lt;code&gt;:::20241&lt;/code&gt;, that&amp;rsquo;s not right. The tunnel config should have &lt;code&gt;url: http://localhost:20241&lt;/code&gt; or equivalent, binding to loopback.&lt;/p&gt;
&lt;p&gt;Any service the tunnel proxies should also bind to &lt;code&gt;127.0.0.1&lt;/code&gt; wherever possible. If a Podman container needs to listen on &lt;code&gt;0.0.0.0:&lt;/code&gt; because it&amp;rsquo;s rootless and the user namespace maps the port, the host firewall handles the restriction. Both work, but binding to localhost removes one layer of trust from the equation.&lt;/p&gt;
&lt;h2 id="the-final-state"&gt;The Final State&lt;/h2&gt;
&lt;p&gt;When you&amp;rsquo;re done, the box should look like this:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Check&lt;/th&gt;
&lt;th&gt;Expected&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Host firewall allowed ports&lt;/td&gt;
&lt;td&gt;&lt;code&gt;22/tcp&lt;/code&gt; only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Services in the tunnel&amp;rsquo;s path&lt;/td&gt;
&lt;td&gt;&lt;code&gt;cloudflared&lt;/code&gt; on &lt;code&gt;127.0.0.1&lt;/code&gt; only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SSH config&lt;/td&gt;
&lt;td&gt;&lt;code&gt;PasswordAuthentication no&lt;/code&gt;, &lt;code&gt;PermitRootLogin no&lt;/code&gt;, &lt;code&gt;AllowUsers&lt;/code&gt; set&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;fail2ban&lt;/td&gt;
&lt;td&gt;Active, jail &lt;code&gt;sshd&lt;/code&gt; running&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SELinux&lt;/td&gt;
&lt;td&gt;Enforcing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Unnecessary services&lt;/td&gt;
&lt;td&gt;Disabled&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;User accounts&lt;/td&gt;
&lt;td&gt;Service accounts use &lt;code&gt;/sbin/nologin&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Nothing else faces the internet. No direct access. No IP on a screen scraping bot&amp;rsquo;s list. The tunnel is the door. Everything else is a wall.&lt;/p&gt;
&lt;h2 id="next"&gt;Next&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;
— self-hosted Git server with CI, running behind the same tunnel infrastructure that gets you started.&lt;/li&gt;
&lt;/ul&gt;</description></item></channel></rss>