GOING FURTHER
Tested for real: I ran the whole Caddy half of this guide top-to-bottom on a fresh Ubuntu 24.04 virtual machine (4 CPUs, 8 GB RAM) on 13 August 2026. Versions at test time: Caddy v2.11.4, Ubuntu 24.04.4 LTS.
And one honest exception: the tunnel half needs a Cloudflare account and their web dashboard, which my test rig does not have. That half is written from Cloudflare's official documentation. (Note: I also use this pattern in production, but this walkthrough was not VM-tested.) Each section below says plainly which kind it is.
Found a problem? Tell me via the contact page.
What you'll end up with
My quick tunnel guide gave you a random address that died when you pressed Ctrl+C. This guide gives you the permanent version: app.YOUR-DOMAIN-HERE and tools.YOUR-DOMAIN-HERE, each reaching a different service on your machine. No ports are opened, and the tunnel survives reboots.
Caddy is the part that makes "each reaching a different service" work. One tunnel arrives at one port; Caddy reads which name the visitor asked for and hands the request to the right service. From my test machine:
$ curl -s -H "Host: app.example.com" http://localhost
<h1>Service one — the app</h1>
$ curl -s -H "Host: tools.example.com" http://localhost
<h1>Service two — the tools</h1>
Two names, one port, two different answers. That is the whole trick.
Who this is for
GOING FURTHER. You need the quick tunnel guide done first — this builds directly on it. The tunnel half also needs a Cloudflare account with a domain on it.
Time and cost
The Caddy half took my test machine about 2 minutes of command time; budget 30 minutes to read as you go. The tunnel half is mostly clicking in a dashboard; budget another 30–45 minutes if your domain is already on Cloudflare. Cost: Caddy is free. At test time, Cloudflare documented the tunnel as available on the free plan. A domain of your own costs money — roughly £10 a year.
Words you'll meet
- Reverse proxy — a program that receives web requests and passes each one to the right service behind it. Caddy plays this role here. (basics)
- Caddyfile — Caddy's configuration file: which names it answers for, and where each one goes.
- Named tunnel — a Cloudflare Tunnel with a permanent identity and your own domain, instead of a random throwaway address. (basics)
- Connector — the
cloudflaredprogram running on your machine, holding the tunnel open from your side. - Public hostname — a name like
app.YOUR-DOMAIN-HEREthat Cloudflare routes into your tunnel. - Origin — tunnel-speak for the service on your machine that finally answers the request. Here, Caddy.
- Token — a long secret string that lets a machine run your tunnel's connector. Treat it like a password.
Placeholders
Anywhere you see CAPS-WITH-DASHES, swap in your own value. This guide uses two: YOUR-DOMAIN-HERE — your own domain, for example example.com — and YOUR-TUNNEL-TOKEN-HERE — the long secret string Cloudflare's dashboard gives you in part two. In my VM test I used example.com stand-ins; the outputs you'll see are real and identical either way.
Before you start
- The quick tunnel guide done — you know what a tunnel is and
cloudflaredis installed from Cloudflare's apt repository. - For the tunnel half: a Cloudflare account (free) with a domain added to it. Cloudflare's own onboarding walks you through pointing a domain at them; I am not repeating it here.
- A user that can use
sudo.
The steps
The guide runs in two parts. Part one (Caddy) is fully VM-tested; part two (the tunnel) is written from Cloudflare's documentation.
Part one — Caddy (tested on the VM)
Everything in this part ran on my test machine, in this order, and the outputs are what it said.
Step 1 — Add Caddy's official software source
Caddy publishes its own apt repository (hosted on Cloudsmith, their release platform). Same pattern as the cloudflared install: fetch the signing key, add the shelf, and the two are tied together with signed-by.
Fetch the signing key:
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
Add the repository line:
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
Your output will vary, but look for:
# Source: Caddy
# Site: https://github.com/caddyserver/caddy
# Repository: Caddy / stable
deb [signed-by=/usr/share/keyrings/caddy-stable-archive-keyring.gpg] https://dl.cloudsmith.io/public/caddy/stable/deb/debian any-version main
[...snipped...]
Check it worked. The line that matters is signed-by=/usr/share/keyrings/caddy-stable-archive-keyring.gpg — packages from this shelf must carry Caddy's signature.
Step 2 — Install Caddy
Refresh the package lists so Ubuntu notices the new shelf:
sudo apt update
You should see something like:
Get:2 https://dl.cloudsmith.io/public/caddy/stable/deb/debian any-version InRelease [14.8 kB]
[...snipped...]
Reading package lists...
Install it — 17 MB down, about 9 seconds on my test machine:
sudo apt install -y caddy
A passing run includes:
Get:1 https://dl.cloudsmith.io/public/caddy/stable/deb/debian any-version/main amd64 caddy amd64 2.11.4 [17.3 MB]
Unpacking caddy (2.11.4) ...
Setting up caddy (2.11.4) ...
Created symlink /etc/systemd/system/multi-user.target.wants/caddy.service → /usr/lib/systemd/system/caddy.service.
That last line means the package set Caddy up as a service that starts with the machine — no extra step needed.
Check it worked. Ask for the version:
caddy version
v2.11.4 h1:XKxkMTgNSizEvKG6QHue6cAsFOteU2qA61w2tKkCWi0=
Your version may be newer — that's fine. Then confirm it is already running:
systemctl status caddy
● caddy.service - Caddy
Loaded: loaded (/usr/lib/systemd/system/caddy.service; enabled; preset: enabled)
Active: active (running) since Thu 2026-08-13 17:00:24 UTC; 5s ago
[...snipped...]
The important line is Active: active (running). And it is already serving a page on port 80:
curl -s http://localhost | grep title
<title>Caddy works!</title>
The important line is the Caddy works! title — Caddy's default page is up.
Step 3 — Start two tiny services to stand behind it
We need two things worth routing to. Two folders, one line of HTML each, served by Python on ports 8001 and 8002 — the same trick as the quick tunnel guide, twice. You will want two spare terminals, one per service.
Make the folders and pages:
mkdir ~/site-one ~/site-two
echo '<h1>Service one — the app</h1>' > ~/site-one/index.html
echo '<h1>Service two — the tools</h1>' > ~/site-two/index.html
In your first spare terminal, serve site one on port 8001:
python3 -m http.server 8001 --directory ~/site-one
In your second spare terminal, serve site two on port 8002:
python3 -m http.server 8002 --directory ~/site-two
Each prints a Serving HTTP on 0.0.0.0 port ... banner and keeps running. Leave them.
Check it worked. Back in your main terminal, ask each one directly:
curl http://localhost:8001
<h1>Service one — the app</h1>
curl http://localhost:8002
<h1>Service two — the tools</h1>
Two services, two ports, two different answers. Now we put one front door on both.
Step 4 — Tell Caddy who answers for which name
Caddy reads its routing from /etc/caddy/Caddyfile. We replace the default with three blocks: one per public name, and a catch-all that refuses anything else. Open it with nano:
sudo nano /etc/caddy/Caddyfile
Delete what is there and make it exactly this, with your own domain in place of the placeholder:
# Two services behind one Caddy, plain HTTP on port 80.
# TLS terminates at Cloudflare's edge; the tunnel delivers plain HTTP here.
http://app.YOUR-DOMAIN-HERE {
reverse_proxy localhost:8001
}
http://tools.YOUR-DOMAIN-HERE {
reverse_proxy localhost:8002
}
# Anything else that reaches port 80 gets a 404, not a service.
http:// {
respond "Not found" 404
}
Save and exit (Ctrl+O, Enter, Ctrl+X).
Two things in there deserve a plain explanation.
Why http:// and not HTTPS? Caddy is famous for automatic HTTPS certificates — and we are deliberately not using that here. The visitor's padlock comes from Cloudflare's edge, and the tunnel from Cloudflare to your machine is itself an encrypted connection. Caddy only ever talks to cloudflared across your own machine's loopback. The http:// prefix tells Caddy: serve plain HTTP on port 80, and do not try to fetch public certificates. Fetching them would fail anyway — behind a tunnel, no inbound port ever reaches Caddy.
Why the catch-all? Without it, requests for names you never configured would fall through to whatever Caddy does by default. With it, anything that is not explicitly yours gets a flat 404. You will meet this same principle again on the Cloudflare side in part two.
Check it worked. Caddy can proof-read the file before you bet the running service on it:
caddy validate --config /etc/caddy/Caddyfile
[...snipped...]
Valid configuration
The important line is Valid configuration.
Step 5 — Reload and prove the routing
Reload picks up the new file without dropping the service — it took 0.12 seconds on my test machine:
sudo systemctl reload caddy
No output means it worked. Now the proof. Your domain does not point anywhere yet, so we impersonate it: the -H "Host: ..." flag sends a request to Caddy carrying whichever name we claim to be asking for. This is exactly what the tunnel will do for real visitors in part two.
curl -s -H "Host: app.YOUR-DOMAIN-HERE" http://localhost
<h1>Service one — the app</h1>
curl -s -H "Host: tools.YOUR-DOMAIN-HERE" http://localhost
<h1>Service two — the tools</h1>
And the catch-all, with a name Caddy has never heard of:
curl -s -i -H "Host: something-else.com" http://localhost | head -3
HTTP/1.1 404 Not Found
Content-Type: text/plain; charset=utf-8
Server: Caddy
The important line is HTTP/1.1 404 Not Found — the refusal is deliberate.
Check it worked. Same port, three names, three correct answers — two services and one refusal. Caddy's half of the job is done and tested.
Part two — the named tunnel (written from the docs)
How this part was written: my test rig has no Cloudflare account, so I could not re-run these steps in the VM. They follow Cloudflare's official documentation for a remotely-managed tunnel. (I also use this pattern in production, but this walkthrough was not VM-tested.) Where part one showed you my terminal, this part describes Cloudflare's dashboard. Their screens do get reshuffled, so if a menu has moved, the official guide is the tie-breaker.
Step 6 — Create the tunnel in the dashboard
Documented, not VM-tested: this step follows Cloudflare's official docs, not a recorded run.
Go to one.dash.cloudflare.com and sign in. In the left menu choose Networks → Tunnels, then Create a tunnel. If you cannot find Tunnels, search the Cloudflare dashboard for "Tunnels".
- Choose the Cloudflared connector type.
- Give the tunnel a name you will recognise in a year — the machine's name is a good choice.
- Save it.
The dashboard now shows install commands for several operating systems, with a long token baked into them.
The token is a secret. Anyone who has it can run a connector for your tunnel and receive traffic meant for your services. Do not paste it into chats, screenshots, notes apps, or anything committed to git. If it ever leaks, the dashboard's refresh token option on the tunnel invalidates the old one.
Step 7 — Connect your machine
Documented, not VM-tested: this step follows Cloudflare's official docs, not a recorded run.
You already installed cloudflared from Cloudflare's apt repository in the quick tunnel guide. (If you ran that guide's Undo, redo its steps 3 and 4 now.) So from the dashboard's Debian command you need only the last line — the one that registers the connector as a service:
sudo cloudflared service install YOUR-TUNNEL-TOKEN-HERE
This creates a systemd service that starts with the machine and reconnects on its own — the grown-up version of the terminal you had to leave open last time.
Check it worked. Two places. On your machine:
systemctl status cloudflared
Look for active (running), as with Caddy in step 2. And in the dashboard, the tunnel's status should turn HEALTHY within a minute.
Step 8 — Map your public hostnames to Caddy
Documented, not VM-tested: this step follows Cloudflare's official docs, not a recorded run.
Still in the dashboard, open your tunnel and go to the Public Hostname tab. Add the first one:
- Subdomain:
app— Domain:YOUR-DOMAIN-HERE - Service: type
HTTP, URLlocalhost:80
Save, then add tools the same way, pointing at the same localhost:80.
Both names point at the same place on purpose. Cloudflare creates the DNS records for you and forwards each visitor's request down the tunnel, with the name they asked for intact. Caddy, as you proved in step 5, reads that name and picks the right service. New service later? One new block in the Caddyfile, one new hostname in the dashboard, no new tunnel.
One principle to carry with you: route names, not everything. Each public hostname you create is a deliberate, named door. Cloudflare ends the ingress list with a catch-all rule that returns an error for anything else. Leave it there, at the bottom, exactly like the 404 block in your Caddyfile. A tunnel with a catch-all "send everything to my machine" rule is how people accidentally publish services they forgot they were running.
Check it worked. From any device, on any network:
curl https://app.YOUR-DOMAIN-HERE
You should get service one's page — over HTTPS, with a real certificate, from Cloudflare's edge, through the tunnel, through Caddy. And https://tools.YOUR-DOMAIN-HERE should return service two.
How to take it down fast
Things you publish, you should know how to unpublish. Three levers, in escalating order:
- One name: dashboard → your tunnel → Public Hostname → delete the hostname. Its DNS record goes with it; the name stops resolving.
- Everything, right now: on your machine,
sudo systemctl stop cloudflared. The tunnel drops and every hostname on it goes dead until you start it again. - Permanently: delete the tunnel in the dashboard, then on your machine
sudo cloudflared service uninstallto remove the service and its stored token.
Something went wrong?
My recorded Caddy run came back clean, so most of what I can offer is the one failure I provoked on purpose, plus the shape of the common tunnel-side mistake:
- You see
404 Not FoundwithServer: Caddywhere a service should be → it means the name the request carried does not match any block in your Caddyfile — the catch-all caught it → check the spelling of the host in/etc/caddy/Caddyfileagainst the hostname in the dashboard, thensudo systemctl reload caddy. I provoked exactly this in step 5 with a made-up name. caddy validatecomplains instead of sayingValid configuration→ it means a typo in the Caddyfile — the error names the line → fix it and validate again; the running service is untouched until you reload.- Your public hostname shows a Cloudflare error page → it means the tunnel or the origin behind it is not answering — the same failure the quick tunnel guide describes → check
systemctl status cloudflared, then the two python terminals, then step 5's curls locally.
Undo all of this
The Caddy half of this undo is tested — I ran it on the test machine and it took under a minute. The tunnel half is the dashboard mirror of what you created.
Tunnel first (dashboard + one command):
- Delete both public hostnames, then delete the tunnel, in the dashboard.
- Remove the connector service and its token from your machine:
sudo cloudflared service uninstall
(To remove the cloudflared program itself, the quick tunnel guide's Undo covers it.)
Now the tested Caddy half. Stop the two python terminals with Ctrl+C in each. Then remove Caddy — and note the asterisk in the output: purge takes /etc/caddy and your Caddyfile with it:
sudo apt remove --purge -y caddy
The following packages will be REMOVED:
caddy*
Removing caddy (2.11.4) ...
Purging configuration files for caddy (2.11.4) ...
The important line is Purging configuration files — the config went with the package.
Remove the repository entry:
sudo rm /etc/apt/sources.list.d/caddy-stable.list
Remove the signing key:
sudo rm /usr/share/keyrings/caddy-stable-archive-keyring.gpg
Refresh the package lists so apt forgets the shelf:
sudo apt update
Delete the two demo sites:
rm -r ~/site-one ~/site-two
Check it worked.
caddy version
caddy: command not found
curl -s --max-time 3 http://localhost
(nothing — Failed to connect to localhost port 80)
Both refusals are the pass mark: the program is gone and port 80 is closed again.
Where to go next
- The services worth putting behind those names: Open WebUI for your local AI is a natural first tenant — one more Caddyfile block, one more hostname.
- For services only you should reach — dashboards, admin panels — a public hostname is the wrong tool even with a login page. That is what Tailscale is for.
- Official references: caddyserver.com/docs (the Caddyfile) and Cloudflare's remote tunnel guide.
Last tested: 13 August 2026 on Ubuntu 24.04.4 (4 vCPU, 8 GB RAM) — Caddy half only, including undo. Tunnel half written from official docs; I also use this pattern in production, but that half was not VM-tested. Versions: Caddy v2.11.4. Measured: install 9 s, reload 0.12 s, undo under a minute.
Tried it? Improved it?
Tell the forum what worked and what didn’t: real experience beats recommendations, and the best answers get folded back into this guide with credit.
Related guides
SSH keys, properly: never type a server password again
Your laptop will hold a small pair of files called an SSH key. Your server will trust it, so logging in never asks for a password — then we turn password logins off completely.
12 min read
First hour with a new Ubuntu server: locking the doors
A fully updated server, a personal account with admin rights, a firewall that blocks everything except SSH, and security updates that install themselves.
10 min read