Going public: expose your K3s App to the internet
In the previous article, we deployed WordPress on a K3s cluster. It runs great but only on your local network. You can reach it at wordpress.home from your laptop, but nobody else in the world can see it.
Today we’re going to fix that. And we’re going to do it without opening a single port on your router.
The problem with “just opening a port”
The classic way to expose a home server to the Internet is port forwarding: you tell your router to forward incoming traffic on port 80/443 to your server’s local IP.
It works. But it has real drawbacks:
- Your home IP address is exposed to the Internet
- Your IP changes every time your router reboots (unless you pay for a static IP)
- You need to manage SSL certificates yourself
- Every port you open is a potential attack surface
There’s a better way.
What is a Cloudflare Tunnel?
Instead of opening a door in your wall (port forwarding), a Cloudflare Tunnel works the other way around: your server reaches out to Cloudflare, and Cloudflare forwards the traffic through that connection.
User's browser
↓ HTTPS
Cloudflare Edge ←──── your server initiates this connection
↓
cloudflared (a small process running in your cluster)
↓
WordPress Service (inside K3s)
↓
WordPress Pods
The key insight: the connection goes outward from your cluster. Your router never needs to know about it. No open ports. No exposed IP. Cloudflare also handles HTTPS automatically your WordPress site gets a real SSL certificate for free.
The small process that creates this outbound tunnel is called cloudflared. We’re going to deploy it directly inside K3s, so Kubernetes manages it just like any other app.
Prerequisites
- A K3s cluster with WordPress running (follow this article if you haven’t done it yet)
- A domain name managed by Cloudflare (the free plan works fine)
kubectlconfigured on your machine
Create the tunnel in Cloudflare Dashboard
Go to dash.cloudflare.com, then navigate to:
Networking → Tunnels → Create a tunnel

- Choose “Cloudflared” as the tunnel type
- Give it a name, for example:
k3s-wordpress - Click “Save tunnel”
On the next page, Cloudflare will show you installation instructions and a token. This token is the credential that proves your cloudflared instance belongs to this tunnel. It looks like a long string of characters:
eyJhIjoiMTIz...YOUR_VERY_LONG_TOKEN_HERE...
Copy this token and keep it somewhere safe we’ll need it in the next step. Don’t close the page yet.
You don’t need to follow Cloudflare’s installation instructions on that page. We’re going to deploy
cloudflaredin Kubernetes our own way.
Store the token as a Kubernetes Secret
Remember in the previous article, we created a Secret to store our database passwords? We’re doing exactly the same thing here, for the exact same reason: you never want a token or password to end up in a file that could be committed to Git or shared accidentally.
Run this command, replacing YOUR_TUNNEL_TOKEN with the token you copied:
kubectl create secret generic cloudflared-token \
--from-literal=token=YOUR_TUNNEL_TOKEN

You should see:
secret/cloudflared-token created
You can verify it exists (without seeing the value that’s the whole point) with:
kubectl get secret cloudflared-token

The token is now safely stored in Kubernetes. No YAML file, no Git history, no risk.
Deploy cloudflared in K3s
Now we deploy cloudflared as a Kubernetes Deployment. If you’ve followed the previous article, this pattern is familiar: a Deployment tells Kubernetes to run a container and keep it alive. If it crashes, Kubernetes restarts it automatically.
Create a file named cloudflared.yaml:
apiVersion: apps/v1
kind: Deployment
metadata:
name: cloudflared
namespace: default
spec:
replicas: 1
selector:
matchLabels:
app: cloudflared
template:
metadata:
labels:
app: cloudflared
spec:
containers:
- name: cloudflared
image: cloudflare/cloudflared:latest
args:
- tunnel
- --no-autoupdate
- run
- --token
- $(TUNNEL_TOKEN)
env:
- name: TUNNEL_TOKEN
valueFrom:
secretKeyRef:
name: cloudflared-token # The Secret we just created
key: token
A few things worth understanding here:
replicas: 1one tunnel is enough. Unlike WordPress, there’s no benefit to running multiple tunnel processes.image: cloudflare/cloudflared:latestthe official Cloudflare image.--no-autoupdatewe let Kubernetes handle updates, not the process itself.$(TUNNEL_TOKEN)Kubernetes automatically injects the value from our Secret here. The container never sees the raw token in its configuration file.
Notice there is no Service object for cloudflared. That’s intentional. A Service is for when other things need to call your app. But cloudflared doesn’t receive calls it initiates a connection to Cloudflare. So it doesn’t need to be discoverable inside the cluster.
Apply it:
kubectl apply -f cloudflared.yaml
You should see:

deployment.apps/cloudflared created
Now watch the pod start up:
kubectl get pods -w
Within a few seconds, you should see a pod named cloudflared-xxxxxxxxx-xxxxx with status Running. Press Ctrl+C to stop watching.

If you go back to the Cloudflare Dashboard on your tunnel’s Overview page, you should now see the status switch to Healthy that’s Cloudflare confirming it received the outbound connection from your cluster.
Add a public hostname route
The tunnel is running, but Cloudflare doesn’t yet know which domain should point to it, or where to send the traffic inside your cluster.
Go back to dash.cloudflare.com → Networking → Tunnels → k3s-wordpress → Overview, and click “Add route”. Choose “Published application”, then fill in the form:
| Field | Value |
|---|---|
| Subdomain | wordpress (or whatever you want) |
| Domain | your Cloudflare domain |
| Path | (leave empty) |
| Service URL | `http://wordpress.default.svc.cluster.local` |

Why that Service URL?
This is Kubernetes’ internal DNS. Every Service in the cluster gets a stable DNS name following the pattern <service-name>.<namespace>.svc.cluster.local. Our WordPress Service is named wordpress and lives in the default namespace so its internal address is wordpress.default.svc.cluster.local.
Since cloudflared runs inside the cluster, it can reach this address directly, without going through any Ingress or external IP. It’s the most direct path possible.
We use http:// (not https://) here because traffic inside the cluster doesn’t need to be encrypted Cloudflare already handles HTTPS on the public side.

Click “Add route”. Cloudflare will automatically create a DNS CNAME record pointing wordpress.yourdomain.com to your tunnel. No manual DNS configuration needed.
Update WordPress URLs
Try opening https://wordpress.yourdomain.com` in your browser. You'll probably see the site load, but something is off links might redirect you towordpress.home`, or images might not load.
That’s because WordPress stores its own URL in the database. Right now it still thinks it lives at wordpress.home. We need to update that.
We can do this directly by running a SQL command inside the MySQL pod. This is the same kubectl exec trick that lets you run commands inside a running container like docker exec, but for Kubernetes.
First, get the exact name of your MySQL pod:
kubectl get pods | grep mysql

You’ll see something like mysql-69455767d-t46d2. Use that name in the command below:
kubectl exec -it mysql-69455767d-t46d2 -- mysql -u wordpress -pYOUR_DB_PASSWORD wordpress -e \
"UPDATE wp_options SET option_value='https://wordpress.yourdomain.com' WHERE option_name IN ('siteurl','home');"
You’ll get a warning:
mysql: [Warning] Using a password on the command line interface can be insecure.
That’s expected and harmless in this context it’s just MySQL reminding you that passwords in shell commands can appear in shell history. No actual error means the command worked.
To confirm the values were updated:
kubectl exec -it mysql-69455767d-t46d2 -- mysql -u wordpress -pYOUR_DB_PASSWORD wordpress -e \
"SELECT option_name, option_value FROM wp_options WHERE option_name IN ('siteurl','home');"
You should see:
+-------------+----------------------------------+
| option_name | option_value |
+-------------+----------------------------------+
| home | https://wordpress.yourdomain.com |
| siteurl | https://wordpress.yourdomain.com |
+-------------+----------------------------------+
Now reload `https://wordpress.yourdomain.com`. Your WordPress site is live on the Internet.
What we built
Here’s the full picture of what’s running:
User's browser
↓ HTTPS (Cloudflare handles SSL)
Cloudflare Edge
↓ encrypted tunnel
cloudflared pod (Deployment in K3s, token stored as Secret)
↓ internal Kubernetes DNS
wordpress.default.svc.cluster.local (ClusterIP Service)
↓ load balanced
WordPress pods (x2)
↓
MySQL pod (ClusterIP Service, internal only)
Kubernetes objects created in this article
| Object | Name | Why |
|---|---|---|
Secret |
cloudflared-token |
Stores the tunnel token securely, never in a file |
Deployment |
cloudflared |
Keeps the tunnel process alive, auto-restarts if it crashes |
That’s it. Two objects. No Ingress update, no NodePort, no LoadBalancer.
Why this approach beats port forwarding
| Port forwarding | Cloudflare Tunnel | |
|---|---|---|
| Your home IP exposed | Yes | No |
| Works if your IP changes | No | Yes |
| SSL certificate | Manual | Automatic |
| Open ports on router | Yes | None |
| DDoS protection | No | Yes (Cloudflare absorbs it) |
The tunnel approach also scales well: if you want to expose another app later (say, Jellyfin or a Node.js API), you just add another route in the Cloudflare Dashboard pointing to a different svc.cluster.local address. No new infrastructure needed.
Have fun!



























































)
)













