Cloudflare On-Demand Tunnel with Custom Domain

Revised article

This is a revised version of an article originally published on [larsolino.dev](https://larsolino. ev/articles/cloudflare-tunnel) on April 17, 2025.

Cloudflare Tunnel lets you expose a local service to the internet without opening inbound ports on your router or publishing your origin IP address. The cloudflared process creates outbound connections to Cloudflare, which forwards incoming requests to your local service.

This guide sets up a locally managed tunnel with a custom domain and a local config.yml. You start the tunnel manually when needed, rather than installing it as a permanent service.

The examples use macOS with Homebrew and a domain managed through Cloudflare.

Note

The tunnel’s routing configuration and credentials are stored locally. DNS records, tunnel registration, and any Cloudflare Access policies are managed through Cloudflare.

Install cloudflared

brew install cloudflared

Authenticate with Cloudflare

Run:

cloudflared tunnel login

This opens a browser where you sign in to Cloudflare and select the domain.

The command creates an account certificate:

~/.cloudflared/cert.pem

It does not create your config.yml.

The certificate authorizes management operations such as creating tunnels and configuring DNS routes. Running an existing tunnel requires its tunnel credentials, described below.

Important

Keep cert.pem and the tunnel credentials out of Git repositories. The account certificate grants broader management permissions than the credentials for an individual tunnel.

Create the tunnel

cloudflared tunnel create my-tunnel

This registers the tunnel and creates a credentials file:

~/.cloudflared/<TUNNEL-ID>.json

Record the tunnel UUID and credentials path printed by the command.

The JSON file contains credentials for running this tunnel. Routing settings belong in the separate YAML configuration.

Creating the tunnel does not start it.

To list your tunnels:

cloudflared tunnel list

Create config.yml

Create:

~/.cloudflared/config.yml

Add the following configuration, replacing <TUNNEL-ID> and <mac-user>:

tunnel: <TUNNEL-ID>
credentials-file: /Users/<mac-user>/.cloudflared/<TUNNEL-ID>.json

ingress:
  - hostname: my-tunnel.example.com
    service: http://localhost:8080
  - service: http_status:404

The fields mean:

  • tunnel: The UUID of your tunnel.
  • credentials-file: The absolute path to its JSON credentials file.
  • ingress: Rules mapping incoming hostnames to local services.
  • service: The destination that cloudflared connects to.

The final rule returns HTTP 404 for requests that do not match an earlier rule. Keep this catch-all rule last.

In this example, your application must be running at:

http://localhost:8080

Validate the ingress configuration:

cloudflared tunnel --config ~/.cloudflared/config.yml ingress validate

Configure DNS

Create the DNS route:

cloudflared tunnel route dns my-tunnel my-tunnel.example.com

This creates a CNAME record pointing to your tunnel’s Cloudflare hostname:

<TUNNEL-ID>.cfargotunnel.com

Important

The hostname in the DNS command must match ingress.hostname in config.yml. Here, both use my-tunnel.example.com.

If a conflicting DNS record already exists, inspect it in the Cloudflare dashboard and update or remove it as appropriate.

This DNS step is normally needed only once. Starting and stopping the tunnel does not require recreating the record.

Run the tunnel

First, start your local application on port 8080.

Then start the tunnel:

cloudflared tunnel --config ~/.cloudflared/config.yml run my-tunnel

Explicitly specifying the configuration file and tunnel makes it clear which settings are being used.

While the tunnel is running, open:

https://my-tunnel.example.com

Press Ctrl+C in the terminal to stop the foreground process.

Note

Publishing an HTTP application through a tunnel makes it reachable through its public hostname. To restrict access, configure a Cloudflare Access application and an allow policy for the intended users.

Run in the background

Start the tunnel with a log file and save its process ID:

nohup cloudflared tunnel \
  --config ~/.cloudflared/config.yml \
  run my-tunnel \
  > ~/.cloudflared/tunnel.log 2>&1 &

echo $! > ~/.cloudflared/tunnel.pid

Read the log:

tail -f ~/.cloudflared/tunnel.log

Stop the saved process:

kill "$(cat ~/.cloudflared/tunnel.pid)"
rm ~/.cloudflared/tunnel.pid

Use the PID while it still identifies that running tunnel process. A stale PID file can refer to a different process later.

Avoid using pkill cloudflared for a specific tunnel: it terminates all matching cloudflared processes.

Background execution does not keep the Mac awake or restart the tunnel after a reboot.

Optional ZSH helper script

Save the following as tunnel.sh:

#!/bin/zsh

CONFIG="${HOME}/.cloudflared/config.yml"
LOGFILE="${HOME}/.cloudflared/tunnel.log"
TUNNEL="my-tunnel"

if [[ "$1" == "-b" ]]; then
  echo "Starting tunnel in the background."
  echo "Log: ${LOGFILE}"

  nohup cloudflared tunnel \
    --config "$CONFIG" \
    run "$TUNNEL" \
    > "$LOGFILE" 2>&1 &

  echo "PID: $!"
  echo "Stop this process with: kill $!"
else
  echo "Starting tunnel in the foreground."

  exec cloudflared tunnel \
    --config "$CONFIG" \
    run "$TUNNEL"
fi

Make it executable:

chmod +x tunnel.sh

Run in the foreground:

./tunnel.sh

Or in the background:

./tunnel.sh -b

For background execution, record the printed PID so you can stop that specific process.

Check tunnel status

You can inspect your tunnel in either dashboard:

  • Cloudflare dashboard → Networking → Tunnels
  • Cloudflare Zero Trust → Networks → Connectors

From the terminal:

cloudflared tunnel info my-tunnel

Caching during development

HTTP traffic through Cloudflare Tunnel can be subject to Cloudflare’s CDN caching rules.

Cloudflare does not cache HTML or JSON by default. Certain static resources, including CSS, JavaScript, and images, are eligible for caching. Actual behavior depends on file extensions, response headers, and configured cache rules.

If changes are not appearing:

  1. Check whether the browser is serving a cached copy.
  2. Check the application’s Cache-Control headers.
  3. Review Cloudflare Cache Rules for the development hostname.
  4. Purge the affected URLs if necessary.

For a development hostname, a Cache Rule that bypasses caching can be more convenient than repeatedly purging the cache.

Using a tunnel for SSH

The configuration above publishes a web application. For SSH access, use a separate hostname and an SSH service rule:

ingress:
  - hostname: ssh.example.com
    service: ssh://localhost:22
  - service: http_status:404

Create the corresponding DNS route:

cloudflared tunnel route dns my-tunnel ssh.example.com

The Mac must have Remote Login enabled so its SSH server is available.

For the client-side cloudflared method:

  • Install cloudflared on the client computer as well.
  • Configure Cloudflare Access to restrict access to the intended users.
  • Add a ProxyCommand to the client’s SSH configuration.

For example, on Windows, assuming cloudflared.exe is available on the PATH used by SSH:

Host macbook-cloudflare
    HostName ssh.example.com
    User <mac-user>
    ProxyCommand cloudflared.exe access ssh --hostname %h

If the executable is not found, use its actual absolute path in the ProxyCommand, enclosed in double quotes if it contains spaces.

Test the connection:

ssh macbook-cloudflare

Cloudflare Access handles authentication at the Cloudflare layer. Your Mac’s SSH server still requires its own authentication, such as an SSH key or your Mac account password.

Once the connection works, select macbook-cloudflare through Remote-SSH: Connect to Host… in VS Code and open the project folder on the Mac.

Resources

Pappenheimer Logo

Site powered by Pappenheimer U-DXP