Exposing Applications from Private Networks Using Cloudflare Tunnel

Exposing Applications from Private Networks Using Cloudflare Tunnel

Applications running in private networks (home labs, NAT-restricted environments, or internal clusters) often need public accessibility without direct internet exposure. Cloudflare Tunnel provides a method to establish outbound-only connections that avoid inbound firewall rules and public IP requirements.

This post is for: System administrators and platform engineers managing applications in private networks who need external access without exposing infrastructure to direct internet traffic. Covers single-host Docker deployments and two Kubernetes approaches: simple service routing and advanced ingress integration.


TL;DR

Problem: Applications in private networks (behind NAT, in home labs, or internal clusters) need public access without exposing infrastructure to inbound connections.

Solution: Cloudflare Tunnel establishes outbound connections to Cloudflare’s edge network, which proxies external requests back through the tunnel.

Three deployment patterns:

  1. Docker Compose: cloudflared container routes directly to application containers (Cloudflare-managed TLS)
  2. Kubernetes + Service: cloudflared pod routes directly to Kubernetes services (Cloudflare-managed TLS, simple)
  3. Kubernetes + Ingress: cloudflared pod routes through ingress controller (cluster-managed TLS, advanced)

Requirements:

  • Cloudflare account with domain DNS managed by Cloudflare
  • Outbound firewall access on port 7844 (QUIC over UDP by default, falls back to HTTP2 over TCP)
  • For Example 3 (Kubernetes + Ingress): existing ingress controller with TLS certificate management

đź”— Cloudflare Tunnel Docs | Official Helm Charts


How Cloudflare Tunnel Works

Cloudflare Tunnel (formerly Argo Tunnel) reverses the traditional client-server model for network exposure. Instead of opening inbound ports and waiting for connections, a local daemon (cloudflared) initiates an outbound connection to Cloudflare’s edge network.

Cloudflare Tunnel architecture showing outbound connection from private network through tunnel to Cloudflare edge, with public users connecting to the edge

Connection flow:

  1. The cloudflared daemon authenticates with Cloudflare using a tunnel token
  2. Establishes a persistent outbound connection to Cloudflare’s edge (port 7844, UDP or TCP)
  3. Cloudflare maps the tunnel to configured DNS routes
  4. External requests to the configured domain reach Cloudflare’s edge
  5. Cloudflare proxies requests through the tunnel to the internal application
  6. Responses follow the reverse path back to the client

This approach eliminates the need for:

  • Inbound firewall rules
  • Public IP addresses
  • Port forwarding configurations
  • Manual TLS certificate management (Cloudflare handles edge termination)

The trade-off is dependency on Cloudflare for DNS and edge routing, which may not be acceptable for all environments.

Example configuration:

  1. Log into the Cloudflare Zero Trust Dashboard
  2. Navigate to Networks → Connectors
  3. Click Create a tunnel and name it (e.g., my-app-tunnel or k8s-cluster-tunnel)
  4. Copy the tunnel token for later use
  5. Configure the Public Hostname routing:
    • Subdomain: The subdomain for your application (e.g., app)
    • Domain: Your domain managed by Cloudflare (e.g., yourdomain.com)
    • Service: The target service URL (see deployment-specific instructions below)

Deployment Approaches

This article presents three deployment patterns, each suited to different infrastructure requirements:

  1. Docker Compose: Single-host deployments with Cloudflare-managed TLS
  2. Kubernetes + Service: Cluster deployments with Cloudflare-managed TLS (simple)
  3. Kubernetes + Ingress: Cluster deployments with cluster-managed TLS (advanced)
AspectDocker ComposeKubernetes + ServiceKubernetes + Ingress
Setup complexityLowLowMedium
Certificate managementCloudflareCloudflareCluster (cert-manager)
RoutingDirect to containerDirect to serviceVia ingress controller
High availabilitySingle containerMultiple replicasMultiple replicas
Use caseSingle-host, dev/testSimple K8s deploymentsExisting ingress setup

Example 1: Docker Compose

For single-host deployments where the application runs in Docker, cloudflared can be deployed as a sidecar container in the same Docker network. Both containers share a custom bridge network, isolating traffic from other containers on the host.

Docker Compose Configuration

Here’s a minimal docker-compose.yml that runs both your app and the Cloudflare tunnel:

docker-compose.yml
version: '3.8'
services:
webapp:
image: your-app-image:latest
container_name: webapp
# Runs app on port 80
networks:
- app-network
cloudflared:
image: cloudflare/cloudflared:latest
container_name: cloudflared
command: tunnel --no-autoupdate run
environment:
- TUNNEL_TOKEN=${TUNNEL_TOKEN}
networks:
- app-network
restart: unless-stopped
networks:
app-network:
driver: bridge

Configuration notes:

  • cloudflared uses Docker’s internal DNS to resolve container names (e.g., webapp)
  • The custom app-network bridge network isolates containers in this compose stack from other Docker containers on the host.
  • The tunnel token authenticates the connection to Cloudflare’s edge
  • restart: unless-stopped ensures the tunnel restarts after system reboots

Cloudflare dashboard configuration for Docker Compose:

In the tunnel’s Public Hostname settings, configure:

  • Service Type: HTTP (for internal container communication)
  • Service URL: webapp:80 (container name and port - adjust the port to match your application)

The service URL uses Docker’s internal DNS, where webapp is the container name from the compose file and 80 is the port your application listens on inside the container.

Cloudflare tunnel public hostname configuration showing subdomain, domain, and service URL settings

Environment Configuration

Create a .env file next to your compose file:

.env
TUNNEL_TOKEN=your-tunnel-token-from-cloudflare

Add .env to .gitignore to prevent committing the tunnel token.

Deploy

Terminal window
docker compose up -d

Check the logs to confirm the tunnel connected:

Terminal window
docker compose logs cloudflared

You should see output indicating successful connection:

2025-01-15T10:30:45Z INF Starting tunnel tunnelID=a1b2c3d4-5678-90ab-cdef-1234567890ab
2025-01-15T10:30:45Z INF Registered tunnel connection connIndex=0 connection=12345678-abcd-1234-efgh-567890abcdef event=0 ip=198.41.192.1 location=ams01 protocol=quic
2025-01-15T10:30:46Z INF Registered tunnel connection connIndex=1 connection=23456789-bcde-2345-fghi-67890abcdef1 event=0 ip=198.41.200.2 location=ams02 protocol=quic
2025-01-15T10:30:47Z INF Registered tunnel connection connIndex=2 connection=34567890-cdef-3456-ghij-7890abcdef12 event=0 ip=198.41.192.3 location=fra01 protocol=quic
2025-01-15T10:30:48Z INF Registered tunnel connection connIndex=3 connection=45678901-defg-4567-hijk-890abcdef123 event=0 ip=198.41.200.4 location=fra02 protocol=quic

The tunnel establishes 4 connections to Cloudflare’s edge network for redundancy and load balancing.

Now test your domain. app.yourdomain.com should route to your container.


Example 2: Kubernetes + Service (Cloudflare SSL)

For Kubernetes deployments where Cloudflare handles TLS termination, route traffic directly to Kubernetes services. This approach is similar to Docker Compose but with Kubernetes’ built-in high availability through multiple replicas.

Traffic flow:

  1. External client → Cloudflare edge
  2. Cloudflare edge → cloudflared pod (via tunnel)
  3. cloudflared pod → Application service
  4. Response follows the reverse path

Cloudflare terminates TLS at the edge and forwards HTTP traffic to the internal service.

Prerequisites

  • Running Kubernetes cluster
  • kubectl and Helm 3 installed
  • Cloudflare tunnel created (copy the tunnel token)

Deploy Application

Create a simple nginx deployment for testing:

Terminal window
kubectl create namespace demo
kubectl create deployment nginx --image=nginx:alpine --port=80 -n demo
kubectl expose deployment nginx --port=80 --target-port=80 -n demo

Verify the service:

Terminal window
kubectl get svc -n demo nginx

Output shows the service ClusterIP (e.g., 10.104.47.162).

Configure Cloudflare Dashboard

In the tunnel’s Public Hostname settings:

  • Subdomain: app
  • Domain: yourdomain.com
  • Service Type: HTTP
  • Service URL: http://nginx.demo.svc.cluster.local:80

The service URL uses Kubernetes DNS format: <service-name>.<namespace>.svc.cluster.local:<port>

Cloudflare tunnel configuration for Kubernetes with external SSL showing service URL pointing to internal Kubernetes service

Deploy Cloudflare Tunnel

Terminal window
# Add Cloudflare Helm repository
helm repo add cloudflare https://cloudflare.github.io/helm-charts
helm repo update
# Deploy the tunnel
helm upgrade --install cloudflare-tunnel cloudflare/cloudflare-tunnel-remote \
--namespace demo \
--create-namespace \
--set cloudflare.tunnel_token="your-tunnel-token"

Verify Deployment

Terminal window
kubectl get pods -n demo
kubectl logs -n demo deployment/cloudflare-tunnel-cloudflare-tunnel-remote

Look for successful connection logs:

INF Starting tunnel tunnelID=...
INF Registered tunnel connection connIndex=0 ... protocol=quic
INF Updated to new configuration config="{\"ingress\":[{\"hostname\":\"app.yourdomain.com\",\"service\":\"http://nginx.demo.svc.cluster.local:80\"}]...

Test your domain: https://app.yourdomain.com should return the nginx welcome page.


Example 3: Kubernetes + Ingress (Cluster SSL)

For clusters with existing ingress controllers and cert-manager, route traffic through the ingress to maintain certificate independence from Cloudflare. This approach requires additional configuration but integrates with existing cluster infrastructure.

Traffic flow:

  1. External client → Cloudflare edge
  2. Cloudflare edge → cloudflared pod (via tunnel)
  3. cloudflared pod → Ingress controller
  4. Ingress controller → Application service
  5. Response follows the reverse path

The ingress controller terminates TLS using certificates managed by cert-manager, independent of Cloudflare.

Prerequisites

  • Kubernetes cluster with ingress controller deployed (e.g., NGINX, Traefik)
  • kubectl and Helm 3 installed
  • Existing application with ingress configured and TLS certificate
  • Cloudflare tunnel created (copy the tunnel token)

Determine Ingress Controller IP

Find the cluster IP of your ingress controller:

Terminal window
kubectl get svc -n ingress-nginx ingress-nginx-controller

Example output:

NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S)
ingress-nginx-controller LoadBalancer 10.96.45.200 192.168.1.100 80:30080/TCP,443:30443/TCP

Note the CLUSTER-IP (e.g., 10.96.45.200) - you’ll need this for the Helm configuration.

Configure Cloudflare Dashboard

In the tunnel’s Public Hostname settings:

  • Subdomain: app
  • Domain: yourdomain.com
  • Service Type: HTTPS
  • Service URL: https://app.yourdomain.com

Note: The service URL is the domain name, not an IP. The hostAliases configuration will resolve this to the ingress controller internally.

Cloudflare tunnel configuration for Kubernetes with internal SSL showing service URL as domain name that resolves via hostAliases

The Certificate Validation Problem

When routing through an ingress controller with cluster-managed certificates, a DNS resolution issue arises:

  • The ingress terminates TLS with a certificate for app.yourdomain.com
  • TLS requires the hostname to match, not an IP address
  • But app.yourdomain.com resolves externally to Cloudflare’s edge IPs
  • We need the cloudflared pod to resolve it to the internal ingress IP instead

Solution: Use Kubernetes hostAliases to override DNS inside the pod:

  • Map app.yourdomain.com → 10.96.45.200 (ingress controller IP) in /etc/hosts
  • When cloudflared connects to https://app.yourdomain.com, it resolves to the internal ingress while presenting the correct hostname for TLS validation

Modify Helm Chart

The upstream cloudflare-tunnel-remote chart doesn’t support hostAliases. You need to modify the chart by copying it locally or forking it.

Required modification to templates/deployment.yaml:

Add hostAliases in the pod spec (after line containing serviceAccountName):

cloudflare-tunnel/templates/deployment.yaml
spec:
template:
spec:
hostAliases:
- ip: { { .Values.tunnelConfig.ipAddress } }
hostnames:
- { { .Values.tunnelConfig.hostName } }
serviceAccountName: { { include "cloudflare-tunnel-remote.serviceAccountName" . } }
containers:
- name: cloudflared
# ... rest of container spec

Add to values.yaml:

values.yaml
tunnelConfig:
ipAddress: '10.96.45.200' # Your ingress controller cluster IP
hostName: 'app.yourdomain.com' # Domain matching your public hostname
cloudflare:
tunnel_token: '' # Your tunnel token

Deploy with Helm

Deploy using your modified chart:

Terminal window
helm upgrade --install cloudflare-tunnel ./cloudflare-tunnel \
--namespace ingress-system \
--create-namespace \
--values cloudflare-tunnel/values.yaml

Verify Deployment

Terminal window
kubectl get pods -n ingress-system
kubectl logs -n ingress-system deployment/cloudflare-tunnel

Look for successful connections:

INF Starting tunnel tunnelID=...
INF Registered tunnel connection connIndex=0 ... protocol=http2
INF Updated to new configuration config="{\"ingress\":[{\"hostname\":\"app.yourdomain.com\",\"service\":\"https://app.yourdomain.com\"}]...

The “Updated to new configuration” line confirms the service routing is configured. With hostAliases, the domain resolves internally to your ingress controller.

Test your domain: https://app.yourdomain.com should route through: Cloudflare edge → tunnel → ingress → your application.

Why Route Through Ingress Instead of Directly to Services

This setup routes traffic from cloudflared → ingress controller → services, rather than directly to service IPs. The reasons:

  1. Certificate independence: The ingress controller manages TLS certificates (via cert-manager or manual configuration), keeping certificate lifecycle independent from Cloudflare
  2. Existing ingress rules: Applications already exposed via ingress continue working without modification
  3. Consistency: Same routing logic applies whether traffic arrives from the tunnel or another source (e.g., internal cluster access)
  4. TLS termination: The ingress controller terminates TLS using its own certificates, avoiding certificate validation issues when routing to plain IP addresses

The hostAliases configuration allows cloudflared to resolve the domain name to the ingress controller’s cluster IP. When Cloudflare forwards a request to https://app.yourdomain.com, the tunnel resolves it to the ingress controller IP (e.g., 10.96.45.200), which then routes it according to existing ingress rules.

Routing through the ingress controller adds an extra network hop compared to direct service routing, which may introduce minimal additional latency. For most applications, this overhead is negligible compared to the benefits of maintaining certificate independence and routing consistency.

Security Considerations

Token management:

  • The tunnel token authenticates the connection to Cloudflare’s edge
  • For Docker: Store in .env files excluded from version control
  • For Kubernetes: Use Sealed Secrets, external secret operators (e.g., External Secrets Operator, Vault), or similar secret management solutions instead of plain Kubernetes Secrets
  • Rotate tokens periodically since a compromised token allows unauthorized traffic routing through your tunnel

Firewall requirements:

  • Outbound connections on port 7844 must be allowed
  • No inbound port requirements

Application security:

  • Cloudflare Tunnel does not replace application-level security
  • Applications remain responsible for input validation, authentication, and authorization
  • In Kubernetes, consider using additional NetworkPolicies to restrict which services the tunnel can reach

Troubleshooting

Tunnel fails to connect with QUIC timeout errors:

ERR Failed to dial a quic connection error="failed to dial to edge with quic: timeout: no recent network activity"

This might mean UDP port 7844 is blocked. By default, cloudflared uses QUIC over UDP for optimal performance. If your network blocks UDP, you need to force HTTP2 over TCP instead. Modify your Helm chart’s templates/deployment.yaml to add the --protocol http2 flag:

cloudflare-tunnel/templates/deployment.yaml
command:
- cloudflared
- tunnel
- --no-autoupdate
- --metrics
- 0.0.0.0:2000
- --protocol # Add these two lines
- http2 # to force TCP instead of UDP
- run

Redeploy the chart for changes to take effect. This is needed in corporate environments or networks with strict egress filtering.

Tunnel connects but traffic doesn’t route:

  • Check the “Updated to new configuration” log line - it shows your service routing
  • Verify the service URL in Cloudflare dashboard matches your actual service
  • For Kubernetes: Test service connectivity from within cluster:
Terminal window
kubectl run -it --rm debug --image=busybox -- wget -O- http://service-name.namespace.svc.cluster.local:port

TLS certificate errors (Example 3 - Kubernetes + Ingress):

  • Confirm hostAliases IP matches ingress controller cluster IP
  • Verify the domain in tunnelConfig.hostName matches the Cloudflare dashboard configuration
  • Check ingress controller has valid TLS certificates for the domain
  • Ensure ingress exists for the domain: kubectl get ingress -A | grep yourdomain

Wrong token or tunnel deleted:

  • Logs show ERR Failed to authenticate or ERR Tunnel not found
  • Verify token is correct and tunnel still exists in Cloudflare dashboard
  • Regenerate tunnel token if needed

Conclusion

Cloudflare Tunnel provides a method to expose applications from private networks without requiring inbound firewall rules or public IP addresses. The outbound-only connection model eliminates traditional security concerns associated with exposing services directly to the internet.

Three deployment patterns cover different use cases:

  1. Docker Compose: Ideal for single-host setups, development, and testing
  2. Kubernetes + Service: Simple Kubernetes deployments with Cloudflare-managed TLS
  3. Kubernetes + Ingress: Advanced setups maintaining cluster-controlled certificate lifecycle

Choose based on your infrastructure:

  • Use Example 1 or 2 for simplicity and Cloudflare-managed certificates
  • Use Example 3 when integrating with existing ingress controllers and cert-manager
  • All three avoid the complexity and security risks of traditional port forwarding

The choice depends on operational requirements, existing infrastructure, and certificate management preferences.


This article presents deployment patterns for Cloudflare Tunnel based on practical implementations in both Docker and Kubernetes environments, demonstrating different approaches to certificate management and traffic routing.