Some time ago I installed k3s on a couple of Raspberry Pi to learn more about Kubernetes. I ended up using this cluster as a home lab. I would use it to test some software I wrote, to try out tools or services that could be deployed on Kubernetes, and I used it to control my home network too. I deployed PiHole to this cluster and configured my router to use PiHole as the DNS server. It worked pretty well for a while until the Pi became noisy because their fans started to fail.

While that setup was up and running, I wanted to be able to access the services running on the cluster even when I wasn’t home. The problem is that I don’t own a public IP and I didn’t want to buy one, not only because I’d have to pay for it, but mainly because I actually didn’t want to expose services that were probably not safe to the public Internet.

I looked into a couple of options, but ended up going with Cloudflare. I was already using Cloudflare’s free plan to manage my DNS and I found out I could use Cloudflare Tunnel to expose my services through Cloudflare’s global network and Cloudflare Access for authentication.

In this post, we’ll deploy a web server and a database to Kubernetes, and expose them to the Internet, through Cloudflare. Note that this setup is not recommended for production! I’m not going to configure authentication or make the service reliable. This is for learning purposes.

The setup

I’m assuming you already have Kubernetes running. If you don’t, spin up one with microk8s or minikube. I tested this setup on microk8s running on my laptop. I’m also assuming you already have a domain managed by Cloudflare. If you don’t, you can create a Cloudflare account and add your domain. You can use Cloudflare Tunnel, Access and DNS with the free plan.

This setup works by deploying a lightweight daemon called cloudflared to your private network, in this case to a local Kubernetes cluster. This daemon can connect to resources in the private network, such as HTTP servers, and will establish an outbound-only connection to Cloudflare’s global network, making it possible to connect to private resources through Cloudflare.

Creating the tunnel

We’ll first create the tunnel, then deploy cloudflared to the cluster. I’m using the CLI, but the same can be done through the Cloudflare Dashboard.

Let me log in to Cloudflare first:

$ cloudflared tunnel login

And now I create a tunnel for my home lab:

$ cloudflared tunnel create home-lab
...
Created tunnel home-lab with id <tunnel-uuid>

We’ll need the tunnel UUID for later steps, but you can fetch it later by running:

$ cloudflared tunnel info

We can also use this command to check if the tunnel has been created successfully.

Alright, now that we have a tunnel, we need to deploy cloudflared and some sample services to Kubernetes, then we can connect cloudflared to the tunnel we just created.

Deploying sample services

We’re going to deploy a web server and a database. We’ll connect cloudflared to the web server via HTTP and to the database via TCP.

Create a file called nginx.yaml with the following content:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx
spec:
  selector:
    matchLabels:
      app: nginx
  replicas: 2
  template:
    metadata:
      labels:
        app: nginx
    spec:
      containers:
        - name: nginx
          image: nginx:latest
          ports:
            - containerPort: 80
---
apiVersion: v1
kind: Service
metadata:
  name: nginx
spec:
  selector:
    app: nginx
  ports:
    - protocol: TCP
      port: 80

Deploy nginx to Kubernetes by running:

$ kubectl create -f nginx.yaml

This will create an nginx deployment and an nginx service listening on port 80. You can check if it was deployed correctly by doing port forwarding:

$ kubectl port-forward svc/nginx 8080:80

And using curl to access the service:

$ curl localhost:8080
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
...

Now we’ll deploy the database. Create a file called postgres.yaml with the following content:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: postgres
spec:
  selector:
    matchLabels:
      app: postgres
  replicas: 1
  template:
    metadata:
      labels:
        app: postgres
    spec:
      containers:
        - name: postgres
          image: postgres:latest
          ports:
            - containerPort: 5432
          env:
            - name: POSTGRES_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: postgres
                  key: password
---
apiVersion: v1
kind: Service
metadata:
  name: postgres
spec:
  selector:
    app: postgres
  ports:
    - protocol: TCP
      port: 5432

We’ll first create a secret with a password:

$ kubectl create secret generic postgres --from-literal=password=<my-password>
secret/postgres created

And now we deploy the service:

$ kubectl create -f postgres.yaml

This will create a postgres deployment and a postgres service listening on port 5432. Test it with port forwarding:

$ kubectl port-forward svc/postgres 5432:5432

Access it using psql:

$ psql -h localhost -U postgres -p 5432
...

postgres=#

Now that we have some sample services running on Kubernetes, we can deploy cloudflared and set up some DNS routes.

Deploying the daemon

We’ll deploy cloudflared as a deployment and we’ll use a ConfigMap to configure the daemon, including the ingress rules to route the traffic to the right service. Note that the cloudflared deployment won’t create the routes. You have to create them yourself.

We’ll create a file called cloudflared.yaml with the following content:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: cloudflared
spec:
  selector:
    matchLabels:
      app: cloudflared
  replicas: 2
  template:
    metadata:
      labels:
        app: cloudflared
    spec:
      containers:
      - name: cloudflared
        image: cloudflare/cloudflared:2024.12.2
        # Run the tunnel at startup with config from config.yaml.
        # config.yaml is the ConfigMap.
        args:
        - tunnel
        - --config
        - /etc/cloudflared/config.yaml
        - run
        livenessProbe:
          httpGet:
            path: /ready
            port: 2000
          failureThreshold: 1
          initialDelaySeconds: 10
          periodSeconds: 10
        volumeMounts:
        # We mount the ConfigMap at /etc/cloudflared/config.yaml.
        - name: config
          mountPath: /etc/cloudflared
          readOnly: true
        # We mount credentials.json (from Secret)
        # at /etc/cloudflared/creds/credentials.json.
        - name: creds
          mountPath: /etc/cloudflared/creds
          readOnly: true
      volumes:
      # Create a volume for credentials.json. It will be read by the daemon
      # during startup.
      - name: creds
        secret:
          secretName: home-lab
      # Create a volume for config.yaml. It will be read by the daemon during
      # startup.
      - name: config
        configMap:
          name: cloudflared
          items:
          - key: config.yaml
            path: config.yaml
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: cloudflared
data:
  # We use this file to configure cloudflared.
  config.yaml: |
    # The name of the tunnel to use. It has to be created previously.
    # To create a tunnel:
    #   cloudflared tunnel create <tunnel-name>
    tunnel: home-lab
    credentials-file: /etc/cloudflared/creds/credentials.json
    metrics: 0.0.0.0:2000
    no-autoupdate: true
    # Route these domains to the local services. The DNS names need to be
    # created previously and routed to the tunnel above.
    # To route a domain to the tunnel:
    #   cloudflared tunnel route dns <tunnel-name> <domain>
    ingress:
    # For HTTP services, we prefix the domain with http://
    - hostname: www.example.com
      service: http://nginx:80
    # This is a TCP connection to a database.
    # We prefix the domain with tcp://
    - hostname: pg.example.com
      service: tcp://postgres:5432
    # The file must contain a catch-all rule. Here we respond with 404 if nothing
    # else matches.
    - service: http_status:404    

If you look at the config file, you’ll notice two hostnames in the ingress part:

...
    ingress:
    - hostname: www.example.com
      service: http://nginx:80
    - hostname: pg.example.com
      service: tcp://postgres:5432
...

Update them with your own domain and take note of them. We’ll configure DNS records for these subdomains later on.

We can deploy cloudflared with:

$ kubectl create -f cloudflared.yaml

Let’s take a look at the logs and see if the daemon is running fine:

$ kubectl logs deploy/cloudflared
...
INF Starting metrics server on [::]:2000/metrics
INF Registered tunnel connection connIndex=0 connection=<uuid>
...

With the daemon running we can create DNS records to point to the tunnel. We need to create DNS records for those domains configured in the ingress. The command will be cloudflared tunnel route dns <tunnel-name> <hostname>:

$ cloudflared tunnel route dns home-lab www.example.com
INF Added CNAME www.example.com which will route to this tunnel tunnelID=<uuid>
$ cloudflared tunnel route dns home-lab pg.example.com
INF Added CNAME pg.example.com which will route to this tunnel tunnelID=<uuid>

And with that we can now access our local resources through Cloudflare:

$ curl https://www.example.com
...
<p><em>Thank you for using nginx.</em></p>
</body>
</html>

For the database, we need to do port forwarding with cloudflared access:

$ cloudflared access tcp --hostname pg.example.com --url localhost:5432
INF Start Websocket listener host=localhost:5432

On another shell:

$ psql -h localhost -U postgres -p 5432
Password for user postgres:
...

postgres=#

Exposing Postgres like this is particularly useful, because we can now use the tunnel as a jumphost. It’s a good practice to keep databases in private networks, and Cloudflare Tunnel for connectivity and Access for authentication is an option that is safe and simple to set up, while providing authentication with identity providers (the free version allows you to authenticate with GitHub). We can also configure Access to request a reason for the access request and require an approval from someone before granting the access. I’ll cover a setup with Access in another post.

Final thoughts

I like using Tunnel and Access because they’re simple to set up, they provide a safe solution, and they’re included in Cloudflare’s free plan. However, I wish Cloudflare would let me manage my tunnels, domains and access applications from Kubernetes too. That would make it easier to automate everything. But I believe it shouldn’t be too hard to create a controller and use the SDK to manage those resources. Something to look into.