DEV Community

Tristan Kilhwan Chai
Tristan Kilhwan Chai

Posted on

[Homelab AI Project] Ep.3 - Zero Inbound Ports. Full Remote Dev Access. The Cloudflare Tunnel Architecture.


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
Enter fullscreen mode Exit fullscreen mode

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:

  1. cloudflared agent (homelab) -> establishes persistent outbound tunnel to Cloudflare Edge
  2. Developer (laptop) -> connects to ssh.your-domain.com (Cloudflare Edge)
  3. Cloudflare Edge -> issues OTP challenge, verifies identity
  4. Cloudflare Edge -> forwards verified traffic through existing tunnel
  5. 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
Enter fullscreen mode Exit fullscreen mode

*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
Enter fullscreen mode Exit fullscreen mode

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:

  1. cloudflared agent (homelab) -> establishes persistent outbound tunnel to Cloudflare Edge
  2. Developer (laptop) -> connects to ssh.your-domain.com (Cloudflare Edge)
  3. Cloudflare Edge -> issues OTP challenge, verifies identity
  4. Cloudflare Edge -> forwards verified traffic through existing tunnel
  5. 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
Enter fullscreen mode Exit fullscreen mode
# 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
# 2. Override the service file
sudo systemctl edit cloudflared
Enter fullscreen mode Exit fullscreen mode
# /etc/systemd/system/cloudflared.service.d/override.conf
[Service]
ExecStart=
ExecStart=/usr/bin/cloudflared tunnel run
EnvironmentFile=/etc/cloudflared/env
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode

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)