---
title: "How to set up a Cloudflare Tunnel"
slug: "set-up-cloudflare-tunnel"
category: "howto"
tags: ["howto", "cloudflare", "tunnel", "networking", "tutorial", "ops"]
status: "stable"
last_updated: 2026-05-14
summary: "Install cloudflared, create a named tunnel, route a hostname through Cloudflare DNS, run it as a service, and verify end-to-end connectivity."
related: ["[[ops/cloudflare]]", "[[ops/cloudflare-security-headers]]", "[[ops/cloudflare-dns]]", "[[ops/hostinger-vps]]", "[[ops/namecheap-dns]]", "[[howto/deploy-to-vercel]]", "[[ops/github-pages]]"]
---

## Overview

A Cloudflare Tunnel connects a server behind NAT or a firewall to the internet without opening inbound ports. The `cloudflared` daemon opens outbound connections to Cloudflare's edge; traffic flows through those connections. Use a tunnel when you want a private VPS or home server reachable over HTTPS with Cloudflare's [[ops/cloudflare-security-headers]] applied, without exposing SSH or a public IP.

## Prerequisites

- A Cloudflare account with the target domain added and nameservers pointed to Cloudflare. See [[ops/cloudflare-dns]].
- A server running the origin service (a web app, a reverse proxy, etc.) that listens on a local port.
- `sudo` access on the origin server to install `cloudflared` and register a systemd service.

## Steps

### 1. Install `cloudflared`

**Ubuntu / Debian**

```bash
curl -L https://pkg.cloudflare.com/cloudflare-main.gpg | \
  sudo gpg --dearmor -o /usr/share/keyrings/cloudflare-main.gpg
echo "deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] \
  https://pkg.cloudflare.com/cloudflared $(lsb_release -cs) main" | \
  sudo tee /etc/apt/sources.list.d/cloudflared.list
sudo apt update && sudo apt install -y cloudflared
cloudflared --version
```

**macOS**

```bash
brew install cloudflared
```

### 2. Authenticate with Cloudflare

```bash
cloudflared tunnel login
```

A browser opens. Select the zone (domain) this tunnel will use. `cloudflared` writes a certificate to `~/.cloudflared/cert.pem`.

### 3. Create a named tunnel

```bash
cloudflared tunnel create my-tunnel
# Tunnel credentials written to /root/.cloudflared/<UUID>.json
```

Record the UUID printed in the output. The credentials file is the tunnel's identity; back it up.

### 4. Create the tunnel config

Write `/etc/cloudflared/config.yml` (or `~/.cloudflared/config.yml` for a user-level tunnel):

```yaml
tunnel: <UUID>
credentials-file: /root/.cloudflared/<UUID>.json

ingress:
  - hostname: app.example.com
    service: http://localhost:3000
  - service: http_status:404
```

The last `service: http_status:404` is a required catch-all. Requests that do not match any hostname rule return a 404. Add more `hostname` entries for additional services on the same server.

### 5. Route the hostname through DNS

```bash
cloudflared tunnel route dns my-tunnel app.example.com
```

This creates a `CNAME` record pointing `app.example.com` to `<UUID>.cfargotunnel.com`. The record is automatically proxied through Cloudflare (orange cloud). Verify it appeared:

```bash
cloudflared tunnel info my-tunnel
```

### 6. Run as a systemd service

```bash
sudo cloudflared service install
sudo systemctl enable --now cloudflared
sudo systemctl status cloudflared
```

`cloudflared service install` writes a systemd unit that reads `/etc/cloudflared/config.yml`. The service starts on boot and restarts on failure.

For user-level installs (non-root), use `cloudflared service install --user`.

## Verify it worked

```bash
# 1. The tunnel is listed as HEALTHY in the Cloudflare dashboard
#    under Zero Trust > Networks > Tunnels.

# 2. The hostname resolves through the tunnel.
curl -sI https://app.example.com | head -3
# HTTP/2 200

# 3. The service restarts cleanly.
sudo systemctl restart cloudflared
curl -sI https://app.example.com | head -1
# HTTP/2 200
```

## Common errors

- **`tunnel login` never opens a browser**. Run on a headless server? Copy the URL from stdout and open it on a different machine, then authenticate. The cert writes back to the server.
- **`ERR_TUNNEL_CONNECTION_FAILED`** in browser. The origin service is not listening on the port in `config.yml`. Verify with `ss -tlnp | grep 3000`.
- **Changes to `config.yml` do not take effect**. The service caches the config. Run `sudo systemctl restart cloudflared`.
- **Multiple tunnels and wrong hostname served**. Each tunnel has its own UUID and credentials file. Confirm the UUID in `config.yml` matches the tunnel created in step 3.
- **TLS handshake errors on the origin**. By default `cloudflared` connects to the origin over plain HTTP. If the origin requires HTTPS, set `service: https://localhost:443` and add `originServerName: app.example.com` under the ingress rule.

## Related

- [[ops/cloudflare]]
- [[ops/cloudflare-security-headers]]
- [[ops/cloudflare-dns]]
- [[ops/hostinger-vps]]
- [[ops/namecheap-dns]]
- [[howto/deploy-to-vercel]]
- [[ops/github-pages]]
