It has been some time that I’ve been searching for a way to expose my self-hosted apps without opening my network to the internet. I’ve finally found the solution : Cloudflare tunnels. They only require an outbound connection and remove the need to expose any ports directly.

Requirements:

  • A Cloudflare-managed domain (you can also transfer an existing domain to Cloudflare)
  • A Kubernetes cluster (in my case, managed with FluxCD following GitOps best practices)

Authenticating and creating the tunnel

First, download cloudflared on your machine

Then authenticate to Cloudflare :

cloudflared tunnel login

This will open your browser, ask for your Cloudflare credentials, and create a cert at ~/.cloudflared/cert.pem

Next, create the tunnel from the CLI. In my case, I want to expose the Linkding app:

cloudflared tunnel create linkding

This command generates a .json file containing the tunnel’s tokens. This is the file that allows us to create a tunnel from our cloudflared deployment to the cloudflare network.

We’ll want to transform this file in a kubernetes secret inside of our cluster :

kubectl create secret generic tunnel-credentials \
--from-file=credentials.json=<YOUR_TUNNEL_ID>.json \
--dry-run=client \
-o yaml > tunnel_credentials.yaml

Encrypting secrets with SOPS and Age

As we are using FluxCD as our continuous deployment tool, we need to keep in mind that all our code is going to get pushed to Github. We need a way to encrypt our secret before pushing it to our repo.

For this we’ll use sops encryption with an age key as described in Flux’s docs

Essentially, we’ll encrypt our tunnel-credentials secret with our age public key. We’ll then want to place our age private key inside of our cluster, and we’ll do it manually. The tunnel-credentials secret will then be decripted by Flux, using the sops private key living inside of our cluster as a secret object.

Let’s first create the age keypair :

# if not already installed
$ brew install sops age

$ age-keygen -o age.agekey
Public key: age1helqcqsh9464r8chnwc2fzj8uv7vr5ntnsft0tn45v2xtz0hpfwq98cmsg

We then imperatively create a secret object on the cluster, containing our age private key

cat age.agekey |
kubectl create secret generic sops-age \
--namespace=flux-system \
--from-file=age.agekey=/dev/stdin

the key name must end with .agekey to be detected as an age key

Now that the private key is in the cluster, we can encrypt the tunnel-credentials file with sops and our age public key

cat age.agekey

sops --age=age1helqcqsh9464r8chnwc2fzj8uv7vr5ntnsft0tn45v2xtz0hpfwq98cmsg \
--encrypt --encrypted-regex '^(data|stringData)$' --in-place tunnel-credentials.yaml

Configuring FluxCD for Decryption

In order for Flux to know it has to decrypt tunnel-credentials with Sops, you’ll need to add this in the Flux Kustomization object pointing to your apps :

  # note that the secret is called sops-age in my cluster
  # it's our age private key
  decryption:
    provider: sops
    secretRef:
      name: sops-age

In my case, I want Flux to be aware that there will be a Sops encrypted file in my /apps/staging directory :

apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
  name: apps
  namespace: flux-system
spec:
  interval: 1m0s
  retryInterval: 1m
  timeout: 5m
  sourceRef:
    kind: GitRepository
    name: flux-system
  path: ./apps/staging
  prune: true
  decryption:
    provider: sops
    secretRef:
      name: sops-age

At this point, we can commit and push our code safely, Flux will pick it up and apply it in our cluster, decoding tunnel-credentials.yaml with the age secret.

Deploying Cloudflared in the Cluster

Right now Flux can manage the tunnel-credentials Secret securely. But nothing is actually running the tunnel yet. We need a cloudflared Deployment in the cluster that connects to Cloudflare’s edge and routes traffic to our internal app.

Here’s an example Deployment and ConfigMap that run cloudflared with the tunnel we created earlier (in my case, linkding):

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:latest
        args:
        - tunnel

        # Points cloudflared to the config file, which configures what
        # cloudflared will actually do. This file is created by a ConfigMap
        # below.
        - --config
        - /etc/cloudflared/config/config.yaml
        - run
        livenessProbe:
          httpGet:
            path: /ready
            port: 2000
          failureThreshold: 1
          initialDelaySeconds: 10
          periodSeconds: 10
        volumeMounts:
        - name: config
          mountPath: /etc/cloudflared/config
          readOnly: true
        # Each tunnel has an associated "credentials file" which authorizes machines
        # to run the tunnel. cloudflared will read this file from its local filesystem,
        # and it'll be stored in a k8s secret.
        - name: creds
          mountPath: /etc/cloudflared/creds
          readOnly: true
      volumes:
      - name: creds
        secret:
          secretName: tunnel-credentials

      # Create a config.yaml file from the ConfigMap below.
      - name: config
        configMap:
          name: cloudflared
          items:
          - key: config.yaml
            path: config.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: cloudflared
data:
  config.yaml: \|
    # Name of the tunnel you want to run
    
    tunnel: linkding

    credentials-file: /etc/cloudflared/creds/credentials.json

    metrics: 0.0.0.0:2000
    no-autoupdate: true

    ingress:
    - hostname: linkding.hervedelaunay.com
      service: http://linkding:9090

    - hostname: hello.example.com
      service: hello_world
    
	- service: http_status:404

This tells cloudflared to:

  • use the credentials we stored in the tunnel-credentials Secret,
  • run the tunnel named linkding,
  • and route requests from linkding.hervedelaunay.com to the Linkding service running in the cluster.

Wrapping Up

With this setup, you can safely manage Cloudflare Tunnel credentials in Git, thanks to FluxCD and SOPS.

Your services stay private - only outbound traffic is needed - while still being accessible securely through Cloudflare’s network.

This approach works not just for Linkding, but for any self-hosted app you want to expose securely.