Introduction
In the previous lab, GitLab was placed behind Cloudflare Access and
protected by identity. HTTPS traffic passed through Nginx, and access to
the web interface required authentication at the edge.
But one part of the system still relied on a traditional assumption: SSH
was reachable via an open port.
This lab continues the evolution. The goal is not to add another
security layer for its own sake, but to change the exposure model
itself. Instead of accepting inbound SSH connections, the host
establishes an outbound tunnel to Cloudflare. Both HTTPS and SSH access
are then mediated through identity.
The trust boundary moves from ports to people.
Goal
Rework a self-hosted GitLab setup so that:
- HTTPS traffic flows through Cloudflare Tunnel.
- SSH access is gated by Cloudflare Access.
- No direct inbound SSH exposure is required.
- The GitLab VM remains disposable and isolated.
This is not about eliminating SSH. It is about changing who gets to
initiate it.
Constraints / Assumptions
The environment remains intentionally simple:
- GitLab CE runs inside a VM.
- The host runs Nginx.
- The domain is managed in Cloudflare.
- Cloudflare Access is already configured (from the previous lab).
- Firewall rules are left unchanged.
- Only free-tier Cloudflare features are used.
The lab focuses on architecture and exposure patterns, not
cloud-provider specifics.
High-Level Design
In the original setup, identity protected HTTP, but SSH still depended
on a reachable port:
User
→ Cloudflare (DNS + Access)
→ Host Nginx (443)
→ GitLab VM
SSH:
User → Host 22 → VM 22
Identity was enforced for the web interface, but the transport layer
still trusted network reachability.
After introducing Cloudflare Tunnel, the model shifts:
User
→ Cloudflare Access
→ Cloudflare Tunnel (outbound from host)
→ Nginx (HTTP)
→ GitLab VM
SSH:
User → Cloudflare Access
→ Cloudflare Tunnel
→ VM (SSH)
The host now initiates and maintains the connection to Cloudflare.
Nothing waits for unsolicited inbound traffic. Access is mediated by
identity first, transport second.
The firewall is no longer the primary boundary. It becomes a fallback.
Phase 1 --- Make It Work
The first objective is straightforward: establish functional
connectivity through the tunnel.
Cloudflared is installed on the host and authenticated. A named tunnel
is created and bound to the domain. DNS records are routed through the
tunnel. Ingress rules are defined so that the main domain forwards to
Nginx locally, and a dedicated SSH subdomain forwards to the VM's SSH
port.
Once the tunnel runs as a systemd service, the web interface loads
through Cloudflare exactly as before. The difference is subtle but
important: the traffic now enters through an outbound tunnel rather than
a directly reachable service.
SSH follows the same pattern. The client uses:
ProxyCommand cloudflared access ssh --hostname ssh-<domain>
Before any SSH handshake reaches the VM, Cloudflare Access enforces
authentication in the browser and issues a short-lived token. Only then
does the SSH session proceed.
From the user's perspective, nothing changes.\
From the architecture's perspective, everything does.
Phase 2 --- Reduce Trust / Harden Access
With the system working, the focus shifts from connectivity to trust
boundaries.
Identity Before Transport
Previously, SSH relied on an open port. Now, it depends on identity
validation at the edge. If authentication fails, transport never begins.
This is a meaningful shift. The system no longer assumes that network
reachability implies legitimacy.
Outbound-Only Connectivity
The host no longer listens passively for SSH. Instead, it maintains an
outbound connection to Cloudflare. Even if inbound port 22 remains open
for the sake of the lab, the architecture no longer depends on it.
The design supports full inbound closure without structural change.
Disposable Infrastructure
Recreating the VM regenerates SSH host keys. Clients detect this change
and require reconciliation via known_hosts. This is not an
inconvenience; it is a reminder that trust in SSH is explicit and
stateful.
Disposable infrastructure surfaces operational truths that static
systems tend to hide.
Lessons Learned
The most important shift in this lab is conceptual.
In the first iteration, identity wrapped around services that were still
network-exposed. In this one, identity becomes the gateway to transport
itself.
When access decisions happen before packets reach the host, the
firewall's role changes. It is no longer the primary defense but a
secondary containment layer.
The tunnel model also simplifies reasoning about exposure. The host
initiates connectivity; it does not advertise it. That inversion removes
an entire class of assumptions about scanning, probing, and open ports.
Finally, rebuilding the VM highlights something often overlooked:
security models must account for operational behavior. SSH host key
changes are not edge cases --- they are part of lifecycle management. A
secure design must remain understandable and manageable under change.
Where to Go Next
A natural extension of this lab is introducing private GitLab Runner
VMs.
These runners would:
- Exist only inside the private network.
- Register with GitLab over internal addresses.
- Execute CI jobs without any public exposure.
This would extend the pattern beyond secure access and into secure
execution. Identity gates entry; isolation protects workload processing.
Repository
The full lab --- including runbook, configuration examples, and tunnel
setup --- is available here:
👉 https://github.com/ic-devops-lab/devops-labs/tree/main/GitLabSEBehindCloudflare02Tunnels
Published Labs in This Series
Each lab explores a boundary in infrastructure design and gradually
shifts trust from network assumptions toward identity and workload
isolation.


Top comments (0)