A Cloudflare Tunnel connects your server to Cloudflare's network through outbound-only connections made by a small daemon called cloudflared. Visitors reach your application through Cloudflare, so the server does not need any inbound HTTP or SSH port open to the internet. In this tutorial you will install cloudflared on Ubuntu 24.04, create a named tunnel, route two hostnames to local services (a web app and SSH), run the tunnel as a systemd service, and put a Zero Trust Access login in front of it.

Prerequisites

To follow this tutorial you need:

  • A server running Ubuntu 24.04 LTS, for example a CubePath VPS, with a non-root user with sudo privileges.
  • A domain whose DNS is managed by Cloudflare (the zone must be active in your Cloudflare account). This guide uses your_domain as a placeholder.
  • A web service listening locally. The examples assume something on http://localhost:8080; any HTTP app works.
  • Outbound connectivity to Cloudflare on port 7844 (TCP and UDP). cloudflared uses it for its tunnel connections; most providers allow outbound traffic by default.
  • A Cloudflare Zero Trust organization for Step 7. The free plan is enough; you create it the first time you open the Zero Trust dashboard.

Step 1 - Installing cloudflared

Cloudflare publishes an APT repository for cloudflared. Installing from it means apt upgrade keeps the daemon current, which matters because old versions eventually stop being supported.

Download the repository signing key:

sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://pkg.cloudflare.com/cloudflare-public-v2.gpg | sudo tee /etc/apt/keyrings/cloudflare-public-v2.gpg > /dev/null

Add the repository. Cloudflare uses the distribution name any, so the same line works on every supported Debian and Ubuntu release:

echo "deb [signed-by=/etc/apt/keyrings/cloudflare-public-v2.gpg] https://pkg.cloudflare.com/cloudflared any main" | sudo tee /etc/apt/sources.list.d/cloudflared.list

Install the package:

sudo apt update
sudo apt install cloudflared

Check the installed version:

cloudflared --version
cloudflared version 2026.x.x (built ...)

Step 2 - Authenticating cloudflared with your account

Before the CLI can create tunnels and DNS records, it needs a certificate that ties it to your Cloudflare account and zone. Run:

cloudflared tunnel login

The command prints a URL. Open it in a browser on any machine, log in to Cloudflare and select your_domain. When you authorize it, cloudflared saves the certificate on the server:

You have successfully logged in.
If you wish to copy your credentials to a server, they have been saved to:
/home/your_user/.cloudflared/cert.pem

This cert.pem can create and delete tunnels in your account, so keep it private. It is only needed for management commands, not for running the tunnel.

Step 3 - Creating the tunnel and DNS records

Create a named tunnel. The name is only a label for you:

cloudflared tunnel create web01
Tunnel credentials written to /home/your_user/.cloudflared/6ff42ae2-765d-4adf-8112-31c55c1551ef.json. cloudflared chose this file based on where your origin certificate was found. Keep this file secret. To revoke these credentials, delete the tunnel.

Created tunnel web01 with id 6ff42ae2-765d-4adf-8112-31c55c1551ef

Write down the tunnel ID (the UUID). The JSON file holds the secret the daemon uses to run this tunnel. The service you install later runs as root and reads its configuration from /etc/cloudflared, so copy the credentials there and restrict the permissions:

sudo install -d -m 0755 /etc/cloudflared
sudo install -m 0600 ~/.cloudflared/6ff42ae2-765d-4adf-8112-31c55c1551ef.json /etc/cloudflared/

Replace the UUID with your own in this and all following commands.

Now create DNS records that point hostnames at the tunnel. cloudflared adds a proxied CNAME to <tunnel-id>.cfargotunnel.com for each one:

cloudflared tunnel route dns web01 app.your_domain
cloudflared tunnel route dns web01 ssh.your_domain
INF Added CNAME app.your_domain which will route to this tunnel tunnelID=6ff42ae2-765d-4adf-8112-31c55c1551ef
INF Added CNAME ssh.your_domain which will route to this tunnel tunnelID=6ff42ae2-765d-4adf-8112-31c55c1551ef

List your tunnels to confirm it exists:

cloudflared tunnel list

Step 4 - Writing the ingress rules

The configuration file tells cloudflared which tunnel to run and where to send each hostname. Rules are matched from top to bottom, and the last rule must be a catch-all without a hostname.

Create the file:

sudo nano /etc/cloudflared/config.yml

Add the following, adjusting the tunnel ID, hostnames and local ports:

tunnel: 6ff42ae2-765d-4adf-8112-31c55c1551ef
credentials-file: /etc/cloudflared/6ff42ae2-765d-4adf-8112-31c55c1551ef.json

ingress:
  - hostname: app.your_domain
    service: http://localhost:8080
  - hostname: ssh.your_domain
    service: ssh://localhost:22
  - service: http_status:404

A few notes on this file:

  • service: http://localhost:8080 sends traffic for app.your_domain to your local web app. Cloudflare terminates HTTPS for visitors, so the local app can stay on plain HTTP bound to 127.0.0.1.
  • If a local service only speaks HTTPS with a self-signed certificate, use service: https://localhost:8443 and add an originRequest block with noTLSVerify: true under that rule.
  • The final http_status:404 rule answers any hostname you did not list.

Validate the file:

cloudflared tunnel --config /etc/cloudflared/config.yml ingress validate
Validating rules from /etc/cloudflared/config.yml
OK

You can also check which rule a given URL matches:

cloudflared tunnel --config /etc/cloudflared/config.yml ingress rule https://app.your_domain
Using rules from /etc/cloudflared/config.yml
Matched rule #0
	hostname: app.your_domain
	service: http://localhost:8080

Step 5 - Running the tunnel as a systemd service

Run the tunnel once in the foreground to catch errors before turning it into a service:

sudo cloudflared tunnel --config /etc/cloudflared/config.yml run

Look for lines saying Registered tunnel connection. Normally there are four, spread over two Cloudflare data centers. Press CTRL+C to stop it.

Install the systemd service. With a locally managed tunnel, cloudflared service install picks up /etc/cloudflared/config.yml, writes a unit file, and enables and starts it:

sudo cloudflared service install

Check the service:

sudo systemctl status cloudflared
● cloudflared.service - cloudflared
     Loaded: loaded (/etc/systemd/system/cloudflared.service; enabled; preset: enabled)
     Active: active (running) since ...

Follow its logs with:

sudo journalctl -u cloudflared -f

From any machine, request the web hostname:

curl -I https://app.your_domain

The response should come from your app, with Cloudflare headers such as server: cloudflare and cf-ray. The tunnel is working.

You can also see the active connections from the server:

cloudflared tunnel info web01

Step 6 - Connecting to SSH through the tunnel

The ssh.your_domain rule forwards to the local SSH daemon, but a plain ssh client cannot speak to Cloudflare's edge directly. The client machine needs cloudflared as a proxy.

On your local machine, install cloudflared (on macOS, brew install cloudflared; on Linux, the same APT repository from Step 1). Then add a host entry to ~/.ssh/config on the client:

Host ssh.your_domain
    ProxyCommand cloudflared access ssh --hostname %h
    User your_user

Connect as usual:

ssh ssh.your_domain

Once you have confirmed that SSH through the tunnel works, and only then, you can stop accepting SSH on the public interface. With UFW, removing the rule looks like this:

sudo ufw delete allow OpenSSH
sudo ufw status

Step 7 - Protecting applications with Zero Trust Access

At this point anyone who knows app.your_domain can reach the app. Cloudflare Access adds a login step at Cloudflare's edge, so unauthenticated requests never reach your server. It is especially useful for admin tools and for the SSH hostname.

In the Cloudflare dashboard, open Zero Trust and do the following:

  1. Under Settings > Authentication, make sure at least one login method is enabled. One-time PIN (a code sent by email) works without any extra setup.
  2. Go to Access > Applications and select Add an application, then Self-hosted.
  3. Give it a name and add app.your_domain as the application domain. Add ssh.your_domain as well, or create a second application for it.
  4. Add a policy with the action Allow and an Include rule such as Emails with your address, or Emails ending in @your_company_domain.
  5. Save the application.

Menu names in the Zero Trust dashboard change from time to time; if a label differs, look for the Access applications section.

Test it by opening https://app.your_domain in a private browser window. You should see the Cloudflare Access login page instead of your app. After entering an allowed email and the one-time code, you are redirected to the application.

For SSH, the next ssh ssh.your_domain opens a browser window for the same login, and cloudflared stores the resulting token locally for the session duration.

Troubleshooting

The service fails to start. Check the logs with sudo journalctl -u cloudflared -n 50. The most common causes are a wrong path in credentials-file, a UUID in tunnel: that does not match the JSON file, or a YAML indentation error. Run the ingress validate command from Step 4 again.

Error 1033 in the browser. Cloudflare cannot find a running connector for the tunnel. Make sure the service is active and that outbound port 7844 is not blocked by a firewall between the server and the internet.

502 Bad Gateway. The tunnel is up, but cloudflared cannot reach the local service. Test it from the server with curl -I http://localhost:8080 and check that the port and scheme in config.yml are correct.

The hostname does not resolve. Confirm that the CNAME exists in the Cloudflare DNS dashboard. If route dns failed because a record with that name already existed, delete the old record and run the command again.

Conclusion

Your server now publishes a web application and SSH through a Cloudflare Tunnel, with no inbound ports needed for them and an identity check in front of both. The tunnel runs as a systemd service and its configuration lives in a single YAML file you can back up or manage with configuration management.

As next steps, you can bind your local services to 127.0.0.1 only so they are never reachable directly, add more hostnames to the ingress list as you deploy new tools, and connect a proper identity provider (Google, GitHub, Okta) to Zero Trust instead of one-time PINs.