
This post is Episode 3 of my Homelab AI Project series, where I document building an AI news automation pipeline on a mini PC homelab server instead of the cloud.
Korean version available on Velog---## Executive Summary
This post details how to build a Zero-Trust, zero-port-forwarding remote development infrastructure for a homelab server using Cloudflare Tunnel (cloudflared) and Cloudflare Access OTP. We'll cover the complete architecture - from OS-native systemd installation on the server side, to ProxyCommand-based VS Code Remote-SSH configuration on the client side - plus the real-world troubleshooting you won't find in the official docs.
1. The Security Anti-Pattern: Why Port Forwarding Is a Liability
Opening inbound ports via router port forwarding is the default "quick fix" for homelab remote access. The consequences are immediate and measurable.
Here's real Fail2ban log data captured 12 hours after enabling port 22 forwarding as a controlled test on our Rocky Linux 10.1 node:
# /var/log/fail2ban.log - 12 hours post-exposure
2026-07-10 02:14:38 fail2ban.actions: NOTICE [sshd] Ban 185.224.128.x (CN)
2026-07-10 02:17:12 fail2ban.actions: NOTICE [sshd] Ban 45.83.65.x (RU)
2026-07-10 03:01:55 fail2ban.actions: NOTICE [sshd] Ban 91.240.118.x (RO)
...
# Total unique IPs banned in 12 hours: 247
# Total failed login attempts logged: 12,481
Over 12,000 automated brute-force attempts in half a day. Each blocked attempt still consumes SSH daemon processing cycles and triggers Fail2ban's iptables rule updates - a persistent, invisible tax on server resources.
Our architecture mandate: Zero public IP exposure. Zero inbound ports open.
2. The Architecture: Outbound-Only Tunnel Model
Cloudflare Tunnel (cloudflared) inverts the traditional connection model entirely.
Instead of opening inbound ports to accept connections, we run a lightweight cloudflared daemon inside our private network that establishes a persistent outbound HTTPS/TCP connection to Cloudflare's global edge network (321+ PoPs as of 2026).
The key security property: Malicious packets - port scans, brute-force attempts, vulnerability probes - never reach the homelab server. They terminate at Cloudflare's edge and are silently dropped before any traffic is forwarded into the private network.
The connection flow:
-
cloudflaredagent (homelab) -> establishes persistent outbound tunnel to Cloudflare Edge - Developer (laptop) -> connects to
ssh.your-domain.com(Cloudflare Edge) - Cloudflare Edge -> issues OTP challenge, verifies identity
- Cloudflare Edge -> forwards verified traffic through existing tunnel
-
cloudflared-> proxies to local SSH daemon (port 22)
3. Server-Side Implementation: OS-Native systemd Installation
A critical architectural decision must be made upfront: do not run cloudflared as a Docker container.
If the Docker daemon crashes or fails to restart after an update, the cloudflared container dies with it - taking your entire SSH tunnel access down. You cannot diagnose what went wrong because the tunnel that would provide SSH access is itself inside the broken Docker stack. This is the equivalent of storing a lifeboat inside the hull of the ship.
cloudflared must live at the OS systemd layer, independent of Docker entirely. If Docker goes down, the systemd-managed tunnel remains alive, allowing SSH access to diagnose and recover the situation.
# 1. Install cloudflared binary via RPM (Rocky Linux / RHEL-based)
curl -L --output cloudflared.rpm \
https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-x86_64.rpm
sudo rpm -i cloudflared.rpm
# 2. Verify installation
$ cloudflared --version
cloudflared version 2026.5.1 (built 2026-05-01-1234 UTC)
# 3. Register as systemd service using tunnel token from Cloudflare Zero Trust dashboard
sudo cloudflared service install $TUNNEL_TOKEN
# 4. Enable service and configure for automatic start at boot
sudo systemctl enable --now cloudflared
# 5. Verify status and measure resource consumption
$ systemctl status cloudflared
* cloudflared.service - Cloudflare Tunnel
Loaded: loaded (/etc/systemd/system/cloudflared.service; enabled)
Active: active (running) since 2026-07-10 09:12:34 KST; 3 days ago
Main PID: 1842 (cloudflared)
Tasks: 8
Memory: 21.4M
CPU: 1.234s
*Memory: 21.4 MB. CPU accumulated: 1.234 seconds over 3 days of uptime*This post is Episode 3 of my Homelab AI Project series, where I document building an AI news automation pipeline on a mini PC homelab server instead of the cloud.
Korean version available on Velog---## Executive Summary
This post details how to build a Zero-Trust, zero-port-forwarding remote development infrastructure for a homelab server using Cloudflare Tunnel (cloudflared) and Cloudflare Access OTP. We'll cover the complete architecture - from OS-native systemd installation on the server side, to ProxyCommand-based VS Code Remote-SSH configuration on the client side - plus the real-world troubleshooting you won't find in the official docs.
1. The Security Anti-Pattern: Why Port Forwarding Is a Liability
Opening inbound ports via router port forwarding is the default "quick fix" for homelab remote access. The consequences are immediate and measurable.
Here's real Fail2ban log data captured 12 hours after enabling port 22 forwarding as a controlled test on our Rocky Linux 10.1 node:
# /var/log/fail2ban.log - 12 hours post-exposure
2026-07-10 02:14:38 fail2ban.actions: NOTICE [sshd] Ban 185.224.128.x (CN)
2026-07-10 02:17:12 fail2ban.actions: NOTICE [sshd] Ban 45.83.65.x (RU)
2026-07-10 03:01:55 fail2ban.actions: NOTICE [sshd] Ban 91.240.118.x (RO)
...
# Total unique IPs banned in 12 hours: 247
# Total failed login attempts logged: 12,481
Over 12,000 automated brute-force attempts in half a day. Each blocked attempt still consumes SSH daemon processing cycles and triggers Fail2ban's iptables rule updates - a persistent, invisible tax on server resources.
Our architecture mandate: Zero public IP exposure. Zero inbound ports open.
2. The Architecture: Outbound-Only Tunnel Model
Cloudflare Tunnel (cloudflared) inverts the traditional connection model entirely.
Instead of opening inbound ports to accept connections, we run a lightweight cloudflared daemon inside our private network that establishes a persistent outbound HTTPS/TCP connection to Cloudflare's global edge network (321+ PoPs as of 2026).
The key security property: Malicious packets - port scans, brute-force attempts, vulnerability probes - never reach the homelab server. They terminate at Cloudflare's edge and are silently dropped before any traffic is forwarded into the private network.
The connection flow:
-
cloudflaredagent (homelab) -> establishes persistent outbound tunnel to Cloudflare Edge - Developer (laptop) -> connects to
ssh.your-domain.com(Cloudflare Edge) - Cloudflare Edge -> issues OTP challenge, verifies identity
- Cloudflare Edge -> forwards verified traffic through existing tunnel
-
cloudflared-> proxies to local SSH daemon (port 22)
3. Server-Side Implementation: OS-Native systemd Installation
A critical architectural decision must be made upfront: do not run cloudflared as a Docker container.
If the Docker daemon crashes or fails to restart after an update, the cloudflared container dies with it - taking your entire SSH tunnel access down. You cannot diagnose what went wrong because the tunnel that would provide SSH access is itself inside the broken Docker stack. This is the equivalent of storing a lifeboat inside the hull of the ship.
cloudflared must live at the OS systemd layer, independent of Docker entirely. If Docker goes down, the systemd-managed tunnel remains alive, allowing SSH access to diagnose and recover the situation.
# 1. Install cloudflared binary via RPM (Rocky Linux / RHEL-based)
curl -L --output cloudflared.rpm \
https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-x86_64.rpm
sudo rpm -i cloudflared.rpm
# 2. Verify installation
$ cloudflared --version
cloudflared version 2026.5.1 (built 2026-05-01-1234 UTC)
# 3. Register as systemd service using tunnel token from Cloudflare Zero Trust dashboard
sudo cloudflared service install $TUNNEL_TOKEN
# 4. Enable service and configure for automatic start at boot
sudo systemctl enable --now cloudflared
# 5. Verify status and measure resource consumption
$ systemctl status cloudflared
* cloudflared.service - Cloudflare Tunnel
Loaded: loaded (/etc/systemd/system/cloudflared.service; enabled)
Active: active (running) since 2026-07-10 09:12:34 KST; 3 days ago
Main PID: 1842 (cloudflared)
Tasks: 8
Memory: 21.4M
CPU: 1.234s
Memory: 21.4 MB. CPU accumulated: 1.234 seconds over 3 days of uptime - effectively idle. The OS boots cloudflared before Docker even starts, and it continues running regardless of Docker's health.
4. Zero-Trust Access Policy Configuration
Within the Cloudflare Zero Trust Dashboard (Access -> Applications), we define two types of access policies:
Web Service Routes:
-
git.your-domain.com-> internal Gitea container (gitea:3000) -
dashboard.your-domain.com-> internal Astro dashboard (astro:4321)
SSH Service Route with MFA Guardrail:
-
ssh.your-domain.com-> local SSH daemon (localhost:22) -
Policy:
Include-> Email ->your-email@your-domain.com- Auth Method: One-time PIN (OTP) - enforced at edge before any tunnel traffic is forwarded
The policy enforcement model is critical: unauthenticated connection attempts to ssh.your-domain.com are terminated at the Cloudflare edge. The OTP challenge is issued before a single packet reaches the homelab server.
5. Client-Side Configuration: Bridging the Tunnel Locally
The developer's external laptop requires a local cloudflared binary to function as the SSH proxy client.
Step 1: Install cloudflared on the client machine
# Windows
winget install Cloudflare.cloudflared
# macOS
brew install cloudflare/cloudflare/cloudflared
Step 2: SSH Config with ProxyCommand
# ~/.ssh/config
Host home-server
HostName ssh.your-domain.com
User your-username
Port 22
ProxyCommand cloudflared access ssh --hostname %h
# Windows: use absolute path if ProxyCommand cannot resolve PATH
# ProxyCommand "C:\Tools\cloudflared.exe" access ssh --hostname %h
IdentityFile ~/.ssh/id_rsa_homelab
TCPKeepAlive yes
ServerAliveInterval 60
Step 3: VS Code Remote-SSH connection
F1 -> SSH: Connect to Host... -> home-server
On first connection, a browser window opens to the Cloudflare Access OTP verification page. After email OTP verification, the tunnel session is established and VS Code's status bar shows [SSH: home-server].
6. Real-World Troubleshooting
Issue 1: ProxyCommand fails silently on Windows when the binary path contains spaces
winget installs cloudflared.exe to a path containing the Windows username (e.g., C:\Users\YourUsername\...). SSH config's ProxyCommand parser splits on spaces, causing the command to fail with a misleading error:
ssh: connect to host ssh.your-domain.com port 22: No such file or directory
Fix: Copy cloudflared.exe to a space-free path (e.g., C:\Tools\cloudflared.exe) and reference it directly:
ProxyCommand "C:\Tools\cloudflared.exe" access ssh --hostname %h
Issue 2: First connection hangs without triggering the OTP browser flow
The ProxyCommand silently times out on the first connection attempt if no local Cloudflare Access session token exists. You must initialize the local token cache explicitly:
cloudflared access login https://ssh.your-domain.com
# Browser opens -> OTP verification -> token cached to ~/.cloudflared/
# Subsequent SSH connections via ProxyCommand reuse this cached token
Issue 3: cloudflared service install embeds TUNNEL_TOKEN in plaintext
Running sudo cloudflared service install $TUNNEL_TOKEN writes the token directly into the ExecStart line of /etc/systemd/system/cloudflared.service. The token is also visible via ps aux and systemctl show. A leaked tunnel token enables full tunnel hijacking.
Fix: Use an EnvironmentFile for secret injection:
# 1. Isolate the token in a root-only env file (chmod 600)
sudo mkdir -p /etc/cloudflared
sudo tee /etc/cloudflared/env << 'EOF'
TUNNEL_TOKEN=your-actual-token-here
EOF
sudo chmod 600 /etc/cloudflared/env
sudo chown root:root /etc/cloudflared/env
# 2. Override the service file
sudo systemctl edit cloudflared
# /etc/systemd/system/cloudflared.service.d/override.conf
[Service]
ExecStart=
ExecStart=/usr/bin/cloudflared tunnel run
EnvironmentFile=/etc/cloudflared/env
sudo systemctl daemon-reload && sudo systemctl restart cloudflared
$ ps aux | grep cloudflared
root 1842 0.0 0.1 cloudflared tunnel run # Token absent from args
7. Architecture Comparison
| Metric | Port Forwarding | Cloudflare Tunnel (OS Native) |
|---|---|---|
| Inbound ports exposed | SSH 22, HTTP 80/443 | 0 |
| Public IP visibility | Exposed in DNS A records | Hidden (Cloudflare IPs only) |
| Brute-force attack surface | Reaches server directly | Terminated at edge |
| Authentication layers | SSH key only | Edge OTP + SSH key (2-factor) |
| Dynamic IP handling | Requires DDNS | Handled automatically |
| SSH access if Docker fails | N/A | [OK] Survives - OS layer is independent |
| Monthly cost | $0 (but attack surface) | $0 (Free Tier) |
| Server resource overhead | CPU ~3% (Fail2ban) | Memory 21 MB, CPU ~0 (idle) |
8. Next Episode
In Episode 4, we design the multi-service Docker Compose architecture - separating PostgreSQL, Redis, the Node.js collector, and the AI engine into isolated Docker networks with controlled inter-service communication.
Follow the series: Velog (Korean) | Dev.to
Top comments (0)