As part of building my self-hosted Hugo publishing workflow, I ended up with a surprisingly useful reminder:
Even if your deployment looks simple, it often relies on more than one network flow.
This came up while tightening firewall rules around my DMZ web server used for Hugo deployments.
The Setup (High Level)
My environment looks roughly like this:
- A trusted workstation where I write content
- An internal Git server (Gitea) on the LAN
- A public-facing web server in a DMZ
- A deployment script that pulls updates and rebuilds the Hugo site
The publishing workflow is:
- Edit content locally
- Commit + push to Git
- Trigger a deploy command remotely
- The web server pulls the latest content and rebuilds the site
Network Flow Overview
The deployment pipeline relies on two separate SSH connections.
Workstation → DMZ Web Server
- Used to trigger the deployment script.
DMZ Web Server → Internal Git Server
- Used by the deployment script to fetch the latest content.
Only these two SSH paths are required for the publishing workflow.## Network Flow Overview
Figure: SSH flows required for the Hugo deployment pipeline.
The Problem: SSH Deployment Broke After Firewall Lockdown
Once everything was running smoothly, I decided to lock down SSH access to the DMZ host.
So I created a firewall rule allowing:
Workstation → DMZ Web Server on port 22
That seemed correct.
But suddenly my deploy script (hillpush) failed.
As a test, I temporarily opened all ports — and everything worked again.
That told me immediately:
Another network dependency was being blocked.
The Key Realisation: Deployment Requires Two SSH Flows
Even though I was only SSH’ing into the web server, the web server itself also needed outbound access.
There are actually two separate SSH connections involved.
1. Workstation → Web Server (Remote Trigger)
This is the obvious one:
| |
Firewall requirement:
Allow SSH from the trusted workstation to the DMZ host.
2. Web Server → Git Server (Pull Latest Content)
Inside the deploy script, the DMZ server runs something like:
| |
That means the DMZ host must also be able to reach the Git server over SSH.
Firewall requirement:
Allow SSH from the DMZ host to the internal Git server.
The Fix: Minimal Rules, Maximum Security
Once I understood the full workflow, the firewall rules became very clean.
Rule A — Trusted Workstation → DMZ Web Server
Allow port 22 only from a trusted source.
Example:
Source: Trusted clients network
Destination: Web server
Port: 22
Rule B — DMZ Web Server → Internal Git Server
Allow outbound SSH only for Git pulls.
Example:
Source: Web server
Destination: Git server
Port: 22
Quick Connectivity Test
A useful test from the DMZ host is:
| |
If that fails, your deploy script will not be able to fetch updates.
Final Result
With both rules in place, I ended up with:
- SSH access locked down to a single trusted machine
- No unnecessary lateral movement between VLANs
- A working automated deployment pipeline
- Only the minimum required ports open
Update: A Third Flow, Outside SSH Entirely
After adding a self-hosted comment system to the site, the picture above turned out to be incomplete — not because either SSH rule was wrong, but because the comments feature depends on a flow that isn’t SSH at all.
The DMZ web server proxies /comments/* requests to a small internal
bridge service, which in turn talks to the Git server’s REST API over
HTTPS — no SSH involved on either leg. It’s easy to overlook this
kind of dependency when you’re specifically auditing “what needs SSH?”,
since a firewall review scoped only to port 22 will never surface it.
Firewall requirement (non-SSH):
Source: DMZ web server
Destination: Comments bridge service
Port: the bridge's HTTP port
Source: Comments bridge service
Destination: Internal Git server
Port: the Git server's HTTPS API port
The lesson generalises beyond SSH: when locking down a DMZ host, map every outbound dependency the app stack has — not just the protocol you happened to be auditing.
Update: A Fourth Flow, and This One Points Outwards
At the end of September 2026 I moved the public site behind a Cloudflare Tunnel. The
DMZ web server now runs cloudflared, which dials out to Cloudflare, and
visitors’ requests come back down that connection. The router’s inbound web
port-forward is gone, so nothing on the internet can reach the web server
directly any more.
Same lesson, new direction: the firewall now needs to allow the DMZ host’s outbound tunnel connection, and nothing inbound for the web at all.
Two side effects caught me out:
Every visitor looked like localhost. Requests arrive from
cloudflaredon the same box, so the web server has to trust the real client address Cloudflare passes along, for example:1 2 3set_real_ip_from 127.0.0.1; set_real_ip_from ::1; real_ip_header CF-Connecting-IP;Firewall-based banning stopped working. fail2ban blocking by source IP at the firewall is useless when all traffic comes from localhost, so bans now happen inside the web server (a map of banned addresses returning 403).
Takeaway
When hardening infrastructure, always map the full flow:
- Who connects to the server?
- What does the server need to connect to?
In my case:
Deployment wasn’t just “SSH in” — it was also “Git pull out”.
If you’re interested in the publishing pipeline itself, see how I implemented a Git-backed comment system using a private Gitea backend.
Comments