You Can Just Publish Things
Your self-hosted app works, but only at home. Here is how to put it on a real domain with real HTTPS, reachable from anywhere, without opening a single port on your router.

Last time, we gave an old laptop a job — a real application, running in Docker, on hardware you already owned. If you did it, you have something genuinely useful sitting on your desk right now.
And you can only use it by walking over to it.
That is the wall everybody hits second. The app answers at http://localhost:8080 and nowhere else. Not from your phone. Not from the office. Not from your partner's laptop in the next room. Today we fix that — properly — and put your app on a real domain, with a real padlock in the address bar, reachable from anywhere on earth.
Without opening a single port on your router.
The obvious answer, and why it is wrong
Search this problem and every result from about 2010 tells you to set up port forwarding: log into your router and add a rule saying "anything arriving on port 8080, send it to that machine." It works. It is also the wrong answer, for four reasons that get worse as you go down the list.
- You just opened your house to the internet. Not metaphorically. Within hours, automated scanners will find that port — this is not a risk, it is a certainty, and there are search engines dedicated to indexing exactly this. From then on, any bug in that one app is a bug in your home network.
- Your address keeps moving. Most home connections get a dynamic IP that your provider rotates whenever it feels like it. Mine changed twice in a single month. Every time it does, your bookmark breaks and you have no idea why.
- There is no HTTPS. Your browser will say so, loudly and in red. And it is right: you would be typing a password over plain HTTP, across the open internet.
- It may not even be possible. A lot of providers now put customers behind CGNAT, which means you share one public address with your neighbours and there is no port for you to forward. No amount of router fiddling fixes this.
So: not that.

Turn the connection around
Here is the idea that makes the whole problem disappear.
Instead of the internet reaching in to your server, your server reaches out to Cloudflare — and holds that connection open. When someone visits your site, their request arrives at Cloudflare's network, and Cloudflare passes it back down the pipe your machine already opened from the inside.
Nothing ever connects in. Your router's inbound ports stay exactly as closed as they are today. Your firewall stays untouched.
This is a Cloudflare Tunnel, and because the connection is outbound it sidesteps every one of the four problems above at once: nothing is exposed, a moving home IP does not matter (the tunnel just reconnects), TLS is handled by Cloudflare for free, and it works perfectly behind CGNAT.
The mental shift: you are not poking a hole in your wall and hoping. You are making a phone call out, and letting visitors talk to you on the line you dialled.
What you need
- The app from last time — or anything that serves HTTP on a port.
- A domain name. About $10 a year. Cloudflare's own registrar sells them at cost, which is the cheapest you will find.
- A free Cloudflare account. The free tier genuinely covers all of this. There is no trial and no card.
- About twenty minutes.

Step 1 — Put your domain on Cloudflare
In the Cloudflare dashboard, add your domain as a site and pick the Free plan. Cloudflare will give you two nameservers and ask you to set them at whichever registrar you bought the domain from. Do that, then wait — it is usually minutes, occasionally a few hours — until the domain shows as Active.
If you bought the domain through Cloudflare in the first place, this step is already done.
Step 2 — Create the tunnel
Head to the Zero Trust dashboard at one.dash.cloudflare.com, then Networks → Tunnels → Create a tunnel, and choose Cloudflared. Give it a name — the name of the machine it will run on is a good default.
Cloudflare then shows you install instructions for every platform. Ignore most of that. What you want is the long string in the command that starts with eyJhIjoi — that is your tunnel token. Copy it somewhere safe; it is a credential, and anyone who has it can run your tunnel.
Cloudflare reorganises this dashboard every few months, so if there is no Networks section where I said, use the search box and type "Tunnels". The concepts below have been stable for years even as the menus move.
Step 3 — Run cloudflared
On the machine your app is running on, one command:
docker run -d \
--name cloudflared \
--restart unless-stopped \
--network host \
cloudflare/cloudflared:latest \
tunnel --no-autoupdate run --token eyJhIjoiPASTE_YOURS_HEREBetter, though — and in the spirit of last time — write it down as a file instead. Create docker-compose.yml:
services:
cloudflared:
image: cloudflare/cloudflared:latest
container_name: cloudflared
restart: unless-stopped
network_mode: host
command: tunnel --no-autoupdate run
environment:
TUNNEL_TOKEN: ${TUNNEL_TOKEN}...and a .env file next to it holding the secret, so your token never ends up in a file you might paste into a chat window:
TUNNEL_TOKEN=eyJhIjoiPASTE_YOURS_HEREThen bring it up and check it in:
docker compose up -d
docker logs cloudflared --tail 20You are looking for a line saying Registered tunnel connection, usually four of them — Cloudflare opens redundant connections to different data centres so a single one dropping does not take you offline. The dashboard should also flip your tunnel to HEALTHY within a few seconds.
The one line that trips everybody up
That network_mode: host is not decoration. Without it, localhost inside the cloudflared container means the container itself — not your machine — so cloudflared looks for your app, finds nothing, and every request 502s. This is the single most common way this setup fails.
On Linux, host networking is the simplest fix. If you are on Docker Desktop for Mac or Windows, host networking behaves differently; there, point the tunnel at http://host.docker.internal:8080 instead. Or put both containers on the same Docker network and use the app's container name as the hostname — http://stirling-pdf:8080 — which is the tidiest option once you have more than one service.
Step 4 — Give it a public hostname
Back in the tunnel you just created, open the Public Hostname tab and add one:
- Subdomain:
pdf - Domain: your domain
- Type: HTTP
- URL:
localhost:8080
Save. Cloudflare writes the DNS record for you — there is nothing to add by hand.
Now open https://pdf.yourdomain.com from your phone, on mobile data, off your home wifi entirely. Your app, with a valid certificate, from anywhere.
Notice you typedhttp://in the service URL buthttps://in the browser, and both are correct. Cloudflare terminates TLS at its edge; the final hop from cloudflared to your app never leaves your machine, so it does not need its own certificate. This confuses everyone once.
Step 5 — Lock the door before you walk away
Stop and read this bit. You have just put an application on the public internet. If it has no login of its own — and plenty of self-hosted tools do not — then anyone who guesses or stumbles across that URL is now using your app, on your hardware, with your files.
Cloudflare gives you the fix for free. In Zero Trust, go to Access → Applications → Add an application → Self-hosted, point it at pdf.yourdomain.com, and add one policy: action Allow, rule Emails, value your own email address.
Now anybody hitting that URL gets a Cloudflare login screen first and has to prove they control an approved email address — a one-time code, no password to manage. Nothing reaches your app until it passes. This is free for up to 50 users, and it takes about ninety seconds.
Do it now rather than later. "Later" has a way of becoming "the weekend my PDF tool got found."
The things that will eat your afternoon
Everything above works. Here is what the tutorials leave out, in rough order of how much time each one has cost me.
Editing config.yml does absolutely nothing
This one is genuinely maddening until you know it. A tunnel created in the dashboard is remotely managed: its routing config lives at Cloudflare, and it silently overrides whatever is in the config file on your machine. You can edit config.yml, restart the container, watch it come up clean — and nothing changes. There is no error. There is no warning.
The tell is one line in the logs:
INF Updated to new configuration version=7 config="{...}"That is cloudflared saying "I just took my instructions from Cloudflare, thanks." If you see it, your file is decorative — make routing changes in the dashboard.
Tunnels created the other way, on the command line with cloudflared tunnel create, are locally managed and behave in exactly the opposite way: the file is authoritative and the dashboard will not show your routes. Both models are fine. Mixing them up is where the hours go.
A 502 is nearly always one of three things
Check them in this order and you will almost never need a fourth:
- cloudflared cannot reach your app. The
localhost-in-a-container trap. Prove the app is actually up from the host first:curl -I http://localhost:8080. If that fails, the tunnel was never the problem. - The hostname is not in the config that is actually authoritative. See above.
- The DNS record points at a different tunnel. Easy to do the moment you have created a second one, and invisible unless you go looking.
Cloudflare can see your traffic
It is encrypted from the browser to Cloudflare, and from Cloudflare to you — but it is decrypted in between, because that is how a reverse proxy works. For a PDF tool or a blog, that is a perfectly reasonable trade. For your password manager or your family photos, it is not. See the next section.
The free plan is for websites, not everything
Tunnels carry HTTP, HTTPS and WebSockets happily. Pushing large media streams or game traffic through one is both against the terms of service and disappointing in practice — your packets are taking a detour through a data centre that may be a long way from you. I have tried. Latency is exactly as bad as it sounds.
Tunnel, or Tailscale?
These get recommended in the same breath and they solve different problems.

Cloudflare Tunnel makes something public. A real URL that anyone can open with nothing installed. Use it for things you want to share: a site, a webhook endpoint, a tool you want to hand to a friend, anything another service needs to reach.
[Tailscale](https://tailscale.com) makes a private network between your own devices. You install a client everywhere, and then your machines can see each other from anywhere as if they were on the same wifi. Traffic is end-to-end encrypted and nobody in the middle can read it — not even Tailscale. Use it for admin panels, SSH, file shares, anything only you should ever touch.
Most of us end up running both, and the dividing line is simple: public things through the tunnel, private things over Tailscale. If you would not be comfortable with a stranger finding the login page, it does not want to be a tunnel.
What you actually just did
If you followed along, you:
- published a service running on your own hardware to the public internet,
- on a domain you own, with a valid TLS certificate,
- behind an identity check you control,
- and your router still has exactly zero inbound ports open.
That is not a hobbyist's approximation of how real systems are deployed. That is how real systems are deployed. The company you work for is doing a more expensive version of the same four things.
Next time: keeping it alive — backups that actually restore, and knowing when something has quietly broken before your users tell you.
You can just publish things. Go give your app an address.
Discussion
Sign in to leave a comment
Uses your Google account — no password needed.