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:

  1. Edit content locally
  2. Commit + push to Git
  3. Trigger a deploy command remotely
  4. The web server pulls the latest content and rebuilds the site

Network Flow Overview

The deployment pipeline relies on two separate SSH connections.

  1. Workstation → DMZ Web Server

    • Used to trigger the deployment script.
  2. 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

flowchart LR W[Trusted Workstation] D[DMZ Web Server] G[Internal Git Server] W -->|SSH deploy trigger| D D -->|git fetch origin| G

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:

1
ssh user@webserver "run-deploy-script.sh"

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:

1
2
3
git fetch origin
git reset --hard origin/main
hugo --minify

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:

1
nc -zv git-server 22

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.

flowchart LR W[Trusted Workstation] D[DMZ Web Server] G[Internal Git Server] C[Comments Bridge Service] W -->|SSH deploy trigger| D D -->|git fetch origin| G D -->|HTTP proxy_pass /comments/*| C C -->|REST API| G

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.

flowchart LR V[Visitors] --> CF[Cloudflare] D[DMZ Web Server] -->|outbound tunnel| CF W[Trusted Workstation] -->|SSH deploy trigger| D D -->|git fetch origin| G[Internal Git Server]

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 cloudflared on the same box, so the web server has to trust the real client address Cloudflare passes along, for example:

    1
    2
    3
    
    set_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