Cloudflare and Ngrok Tunnels

How is this any different than ngrok?

Posted by Isaac on Friday, October 16, 2026

I recently came across this Cloudflare blog article on Quick Tunnels. They seem like lightweight ngrok tunnels, but without needing any auth. The article suggested they were great to use with agentic flows, but I have a hard time these are things we get just for free - no account required.

We’ll look into Cloudflared Tunnels on the CLI, how we might set them up in Kubernetes then compare these things to what Ngrok has to offer.

Let’s start with the cloudflared CLI

Cloudflared Setup

To start, we need to first install their open-source CloudFlared CLI. It’s written in go and there are precompiled releases on the Cloudflare download page

I’ll download and install the Debian package (I run Ubuntu)

$ wget https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64.deb
--2026-10-15 16:23:22--  https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64.deb
Resolving github.com (github.com)... 140.82.113.4
Connecting to github.com (github.com)|140.82.113.4|:443... connected.
HTTP request sent, awaiting response... 302 Found
Location: https://github.com/cloudflare/cloudflared/releases/download/2026.10.1/cloudflared-linux-amd64.deb [following]
--2026-10-15 16:23:23--  https://github.com/cloudflare/cloudflared/releases/download/2026.10.1/cloudflared-linux-amd64.deb
Reusing existing connection to github.com:443.
HTTP request sent, awaiting response... 302 Found
Location: https://release-assets.githubusercontent.com/github-production-release-asset/106867604/1f3e7deb-60bc-4bf4-95d5-47a41e90f723?sp=r&sv=2018-11-09&sr=b&spr=https&se=2026-10-15T22%3A05%3A31Z&rscd=attachment%3B+filename%3Dcloudflared-linux-amd64.deb&rsct=application%2Foctet-stream&skoid=96c2d410-5711-43a1-aedd-ab1947aa7ab0&sktid=398a6654-997b-47e9-b12b-9515b896b4de&skt=2026-10-15T21%3A04%3A40Z&ske=2026-10-15T22%3A05%3A31Z&sks=b&skv=2018-11-09&sig=v75ZtGzQqM1JFgFU0LcpAL0uWU%2F6q7NgPhEGvxUlulA%3D&jwt=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmVsZWFzZS1hc3NldHMuZ2l0aHVidXNlcmNvbnRlbnQuY29tIiwia2V5Ijoia2V5MSIsImV4cCI6MTc5MjEwMTIwMywibmJmIjoxNzkyMDk5NDAzLCJwYXRoIjoicmVsZWFzZWFzc2V0cHJvZHVjdGlvbi5ibG9iLmNvcmUud2luZG93cy5uZXQifQ.Ey43Ub3rjBDshGv-6ATTITTXoDWF6gI0ZLGOlk-7Im0&response-content-disposition=attachment%3B%20filename%3Dcloudflared-linux-amd64.deb&response-content-type=application%2Foctet-stream [following]
--2026-10-15 16:23:23--  https://release-assets.githubusercontent.com/github-production-release-asset/106867604/1f3e7deb-60bc-4bf4-95d5-47a41e90f723?sp=r&sv=2018-11-09&sr=b&spr=https&se=2026-10-15T22%3A05%3A31Z&rscd=attachment%3B+filename%3Dcloudflared-linux-amd64.deb&rsct=application%2Foctet-stream&skoid=96c2d410-5711-43a1-aedd-ab1947aa7ab0&sktid=398a6654-997b-47e9-b12b-9515b896b4de&skt=2026-10-15T21%3A04%3A40Z&ske=2026-10-15T22%3A05%3A31Z&sks=b&skv=2018-11-09&sig=v75ZtGzQqM1JFgFU0LcpAL0uWU%2F6q7NgPhEGvxUlulA%3D&jwt=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmVsZWFzZS1hc3NldHMuZ2l0aHVidXNlcmNvbnRlbnQuY29tIiwia2V5Ijoia2V5MSIsImV4cCI6MTc5MjEwMTIwMywibmJmIjoxNzkyMDk5NDAzLCJwYXRoIjoicmVsZWFzZWFzc2V0cHJvZHVjdGlvbi5ibG9iLmNvcmUud2luZG93cy5uZXQifQ.Ey43Ub3rjBDshGv-6ATTITTXoDWF6gI0ZLGOlk-7Im0&response-content-disposition=attachment%3B%20filename%3Dcloudflared-linux-amd64.deb&response-content-type=application%2Foctet-stream
Resolving release-assets.githubusercontent.com (release-assets.githubusercontent.com)... 185.199.110.133, 185.199.111.133, 185.199.108.133, ...
Connecting to release-assets.githubusercontent.com (release-assets.githubusercontent.com)|185.199.110.133|:443... connected.
HTTP request sent, awaiting response... 200 OK
Length: 19897896 (19M) [application/octet-stream]
Saving to: ‘cloudflared-linux-amd64.deb’

cloudflared-linux-amd64.deb       100%[============================================================>]  18.98M  55.8MB/s    in 0.3s

2026-10-15 16:23:23 (55.8 MB/s) - ‘cloudflared-linux-amd64.deb’ saved [19897896/19897896]

$ sudo dpkg -i ./cloudflared-linux-amd64.deb
[sudo: authenticate] Password:
Selecting previously unselected package cloudflared.
(Reading database… 316725 files and directories currently installed.)
Preparing to unpack ./cloudflared-linux-amd64.deb…
Unpacking cloudflared (2026.10.1)…
Setting up cloudflared (2026.10.1)…
Processing triggers for man-db (2.13.1-1build1)…

Next, I have an app that runs on :80 locally today

/img/2026-10-cloudflare-01.png

Which works just fine

/img/2026-10-cloudflare-02.png

I can now fire up the tunnel with cloudflared tunnel --url http://localhost:80

/img/2026-10-cloudflare-03.png

And indeed, we see it serving traffic

/img/2026-10-cloudflare-04.png

Restricting Access

With cloudflared tunnels, we can do something kind of interesting

$ cloudflared tunnel –url http://localhost:8080 –allowed-mail isaac.johnson@gmail.com

$ cloudflared tunnel --url http://localhost:80 --allowed-mail isaac.johnson@gmail.com
2026-10-15T21:42:36Z INF Thank you for trying Cloudflare Tunnel. Doing so, without a Cloudflare account, is a quick way to experiment and try it out. However, be aware that these account-less Tunnels have no uptime guarantee, are subject to the Cloudflare Online Services Terms of Use (https://www.cloudflare.com/website-terms/), and Cloudflare reserves the right to investigate your use of Tunnels for violations of such terms. If you intend to use Tunnels in production you should use a pre-created named tunnel by following: https://developers.cloudflare.com/cloudflare-one/connections/connect-apps
2026-10-15T21:42:36Z INF Requesting new quick Tunnel on trycloudflare.com...
2026-10-15T21:42:39Z INF +------------------------------------------------------------------------------------------------------+
2026-10-15T21:42:39Z INF |  Your protected quick Tunnel has been created! Visit it at (it may take some time to be reachable):  |
2026-10-15T21:42:39Z INF |  https://considers-notifications-madrid-updating.trycloudflare.com                                   |
2026-10-15T21:42:39Z INF |  Authentication: One-Time PIN (using Cloudflare Access)                                              |
2026-10-15T21:42:39Z INF |  Allowed recipients: 1 address                                                                       |
2026-10-15T21:42:39Z INF |  Local origin: http://localhost:80                                                                   |
2026-10-15T21:42:39Z INF +------------------------------------------------------------------------------------------------------+
2026-10-15T21:42:39Z INF Cannot determine default configuration path. No file [config.yml config.yaml] in [~/.cloudflared ~/.cloudflare-warp ~/cloudflare-warp /etc/cloudflared /usr/local/etc/cloudflared]
2026-10-15T21:42:39Z INF Version 2026.10.1 (Checksum 3bfc539f45d2620c71debb7a86be0efa737ed5ccbad9eb85c787116900e5c586)
2026-10-15T21:42:39Z INF GOOS: linux, GOVersion: go1.27.2, GoArch: amd64
2026-10-15T21:42:39Z INF Settings: map[allowed-mail:***** ha-connections:1 protocol:quic url:http://localhost:80]
2026-10-15T21:42:39Z INF cloudflared will not automatically update if installed by a package manager.
2026-10-15T21:42:39Z INF Generated Connector ID: 10e16c37-ddcc-44a6-a540-265b776015e6
2026-10-15T21:42:39Z INF Initial protocol quic
2026-10-15T21:42:39Z INF ICMP proxy will use 192.168.1.160 as source for IPv4
2026-10-15T21:42:39Z INF ICMP proxy will use fd39:b4c2:d84b:5bba:aaa1:59ff:fe6b:675d in zone enp5s0 as source for IPv6
2026-10-15T21:42:39Z INF ICMP proxy will use 192.168.1.160 as source for IPv4
2026-10-15T21:42:39Z INF ICMP proxy will use fd39:b4c2:d84b:5bba:aaa1:59ff:fe6b:675d in zone enp5s0 as source for IPv6
2026-10-15T21:42:39Z INF Starting metrics server on 127.0.0.1:20241/metrics
2026-10-15T21:42:39Z INF Tunnel connection curve preferences: [X25519MLKEM768 CurveID(65074) CurveP256] connIndex=0 event=0 ip=198.41.192.7
2026-10-15T21:42:40Z INF Registered tunnel connection connIndex=0 connection=24a60104-85db-4439-bdff-814ee07e4abb event=0 ip=198.41.192.7 location=ord16 protocol=quic
2026-10-15T21:42:46Z INF +-------------------------------------------------------------------------------------+
2026-10-15T21:42:46Z INF |                               CONNECTIVITY PRE-CHECKS                               |
2026-10-15T21:42:46Z INF +-------------------------------------------------------------------------------------+
2026-10-15T21:42:46Z INF |  COMPONENT         TARGET                     STATUS  DETAILS                       |
2026-10-15T21:42:46Z INF |  DNS Resolution    region1.v2.argotunnel.com  PASS    DNS Resolved successfully     |
2026-10-15T21:42:46Z INF |  DNS Resolution    region2.v2.argotunnel.com  PASS    DNS Resolved successfully     |
2026-10-15T21:42:46Z INF |  UDP Connectivity  region1.v2.argotunnel.com  PASS    QUIC connection successful    |
2026-10-15T21:42:46Z INF |  UDP Connectivity  region2.v2.argotunnel.com  PASS    QUIC connection successful    |
2026-10-15T21:42:46Z INF |  TCP Connectivity  region1.v2.argotunnel.com  PASS    HTTP/2 connection successful  |
2026-10-15T21:42:46Z INF |  TCP Connectivity  region2.v2.argotunnel.com  PASS    HTTP/2 connection successful  |
2026-10-15T21:42:46Z INF |  Cloudflare API    api.cloudflare.com:443     PASS    API is reachable              |
2026-10-15T21:42:46Z INF |                                                                                     |
2026-10-15T21:42:46Z INF |  SUMMARY: Environment is healthy. cloudflared will use 'quic' as primary protocol.  |
2026-10-15T21:42:46Z INF +-------------------------------------------------------------------------------------+
2026-10-15T21:42:46Z INF precheck component="DNS Resolution" details="DNS Resolved successfully" run_id=c5806717-5eb4-4a2e-9b21-fc02b9a0f4c7 status=pass target=region1.v2.argotunnel.com
2026-10-15T21:42:46Z INF precheck component="DNS Resolution" details="DNS Resolved successfully" run_id=c5806717-5eb4-4a2e-9b21-fc02b9a0f4c7 status=pass target=region2.v2.argotunnel.com
2026-10-15T21:42:46Z INF precheck component="UDP Connectivity" details="QUIC connection successful" run_id=c5806717-5eb4-4a2e-9b21-fc02b9a0f4c7 status=pass target=region1.v2.argotunnel.com
2026-10-15T21:42:46Z INF precheck component="UDP Connectivity" details="QUIC connection successful" run_id=c5806717-5eb4-4a2e-9b21-fc02b9a0f4c7 status=pass target=region2.v2.argotunnel.com
2026-10-15T21:42:46Z INF precheck component="TCP Connectivity" details="HTTP/2 connection successful" run_id=c5806717-5eb4-4a2e-9b21-fc02b9a0f4c7 status=pass target=region1.v2.argotunnel.com
2026-10-15T21:42:46Z INF precheck component="TCP Connectivity" details="HTTP/2 connection successful" run_id=c5806717-5eb4-4a2e-9b21-fc02b9a0f4c7 status=pass target=region2.v2.argotunnel.com
2026-10-15T21:42:46Z INF precheck component="Cloudflare API" details="API is reachable" run_id=c5806717-5eb4-4a2e-9b21-fc02b9a0f4c7 status=pass target=api.cloudflare.com:443
2026-10-15T21:42:46Z INF precheck complete hard_fail=false run_id=c5806717-5eb4-4a2e-9b21-fc02b9a0f4c7 suggested_protocol=quic

This new URL shows a signin page

/img/2026-10-cloudflare-05.png

I can type in a code that I got in email

/img/2026-10-cloudflare-06.png

While a valid code, my email wasn’t in the allowed list, so I see “Forbidden”

/img/2026-10-cloudflare-07.png

Now if I use my gmail in the window

/img/2026-10-cloudflare-08.png

I can get the code sent to Gmail

/img/2026-10-cloudflare-11.png

When using it, it then says completing auth

/img/2026-10-cloudflare-09.png

Before getting my page back

/img/2026-10-cloudflare-10.png

Kubernetes

That might work great for Docker / local containers, but what about Kubernetes?

Let’s start by creating a tunnel

Spoiler: This approach will create a Cloudflared tunnel, but requires a domain to get pointed here so I will ultimately move on to a different approach…

/img/2026-10-ngrok-09.png

This will give us a tunnel id and token (in the steps)

/img/2026-10-ngrok-10.png

I’ll put that token into a k8s secret

$ cat k8s_cloudflare_secret.yaml | sed 's/token: .*/token: xxxxxxxxxxxxxxxx/'
apiVersion: v1
kind: Secret
metadata:
  name: tunnel-token
stringData:
  token: xxxxxxxxxxxxxxxx

$ kubectl apply -f ./k8s_cloudflare_secret.yaml
  secret/tunnel-token created

$ kubectl get secret tunnel-token
  NAME           TYPE     DATA   AGE
  tunnel-token   Opaque   1      40s

Next, we’ll create and apply a deployment that will spin up cloudflared containers using the token

(base) builder@LuiGi:~/Workspaces/mncampsiteapp$ cat cloudflare_tunnel.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: cloudflared-deployment
  namespace: default
spec:
  replicas: 2
  selector:
    matchLabels:
      pod: cloudflared
  template:
    metadata:
      labels:
        pod: cloudflared
    spec:
      securityContext:
        sysctls:
          # Allows ICMP traffic (ping, traceroute) to resources behind cloudflared.
          - name: net.ipv4.ping_group_range
            value: "65532 65532"
      containers:
        - image: cloudflare/cloudflared:latest
          name: cloudflared
          env:
            # Defines an environment variable for the tunnel token.
            - name: TUNNEL_TOKEN
              valueFrom:
                secretKeyRef:
                  name: tunnel-token
                  key: token
          command:
            # Configures tunnel run parameters
            - cloudflared
            - tunnel
            - --no-autoupdate
            - --loglevel
            - info
            - --metrics
            - 0.0.0.0:2000
            - run
          livenessProbe:
            httpGet:
              # Cloudflared has a /ready endpoint which returns 200 if and only if
              # it has an active connection to Cloudflare's network.
              path: /ready
              port: 2000
            failureThreshold: 1
            initialDelaySeconds: 10
            periodSeconds: 10
(base) builder@LuiGi:~/Workspaces/mncampsiteapp$ kubectl apply -f cloudflare_tunnel.yaml
deployment.apps/cloudflared-deployment created
(base) builder@LuiGi:~/Workspaces/mncampsiteapp$ kubectl get deployments cloudflared-deployment
NAME                     READY   UP-TO-DATE   AVAILABLE   AGE
cloudflared-deployment   2/2     2            2           13s

I have a lot of services in my cluster, so let’s pick something I know is running like toolstack

/img/2026-10-ngrok-11.png

I can now fetch the Kubernetes service details

(base) builder@LuiGi:~/Workspaces/mncampsiteapp$ kubectl get ingress | grep -i toolsta
toolstack-ingress                    <none>   toolbox.tpk.pw                                                  80, 443   225d
(base) builder@LuiGi:~/Workspaces/mncampsiteapp$ kubectl get svc | grep -i toolsta
toolstack-service                                       ClusterIP   10.43.126.174   <none>        80/TCP                                                        225d
(base) builder@LuiGi:~/Workspaces/mncampsiteapp$ kubectl get svc toolstack-service -o yaml
apiVersion: v1
kind: Service
metadata:
  annotations:
    kubectl.kubernetes.io/last-applied-configuration: |
      {"apiVersion":"v1","kind":"Service","metadata":{"annotations":{},"name":"toolstack-service","namespace":"default"},"spec":{"ports":[{"port":80,"protocol":"TCP","targetPort":3000}],"selector":{"app":"toolstack"}}}
  creationTimestamp: "2026-03-03T23:58:19Z"
  name: toolstack-service
  namespace: default
  resourceVersion: "189757141"
  uid: 352e585b-3ac7-4dc3-a6f3-c049fb8c4b42
spec:
  clusterIP: 10.43.126.174
  clusterIPs:
  - 10.43.126.174
  internalTrafficPolicy: Cluster
  ipFamilies:
  - IPv4
  ipFamilyPolicy: SingleStack
  ports:
  - port: 80
    protocol: TCP
    targetPort: 3000
  selector:
    app: toolstack
  sessionAffinity: None
  type: ClusterIP
status:
  loadBalancer: {}

I’m now going to create a route

/img/2026-10-ngrok-12.png

The documentation doesn’t line up now to the UI, but I believe we are looking to add a route to a hostname (second option)

/img/2026-10-ngrok-13.png

I just need the service name

/img/2026-10-ngrok-14.png

I can see the tunnel

/img/2026-10-ngrok-15.png

And routes

/img/2026-10-cloudflare-20.png

But nowhere does it clue me in on how to reach it.

I’ll try their AI tool

/img/2026-10-cloudflare-21.png

Seems I need to make a zone. While they have a free tier there, I still need to seemingly have a real TLD to point to them

/img/2026-10-cloudflare-22.png

which isn’t really the lightweight option i wanted.

I feel there is a way so I asked Agy for some help

/img/2026-10-cloudflare-23.png

And got a helm update

(base) builder@LuiGi:~/Workspaces/mncampsiteapp$ git diff helm/
diff --git a/helm/mncampsiteapp/templates/NOTES.txt b/helm/mncampsiteapp/templates/NOTES.txt
index b8b70f3..d3b5f8a 100644
--- a/helm/mncampsiteapp/templates/NOTES.txt
+++ b/helm/mncampsiteapp/templates/NOTES.txt
@@ -15,3 +15,10 @@
 2. Port-forward to test locally:
    kubectl --namespace {{ .Release.Namespace }} port-forward svc/{{ include "mncampsiteapp.fullname" . }} 8000:{{ .Values.service.port }}
 {{- end }}
+{{- if .Values.cloudflared.enabled }}
+
+Cloudflare Ephemeral Quick Tunnel is enabled!
+To retrieve your generated *.trycloudflare.com URL, run:
+  kubectl --namespace {{ .Release.Namespace }} logs deploy/{{ include "mncampsiteapp.fullname" . }} -c cloudflared | grep trycloudflare.com
+{{- end }}
+
diff --git a/helm/mncampsiteapp/templates/deployment.yaml b/helm/mncampsiteapp/templates/deployment.yaml
index 40fd2b5..df6f719 100644
--- a/helm/mncampsiteapp/templates/deployment.yaml
+++ b/helm/mncampsiteapp/templates/deployment.yaml
@@ -52,6 +52,37 @@ spec:
           volumeMounts:
             - name: mn-campsite-data
               mountPath: {{ .Values.persistence.mountPath }}
+        {{- if .Values.cloudflared.enabled }}
+        - name: cloudflared
+          image: "{{ .Values.cloudflared.image.repository }}:{{ .Values.cloudflared.image.tag }}"
+          imagePullPolicy: {{ .Values.cloudflared.image.pullPolicy }}
+          command:
+            - cloudflared
+            - tunnel
+            - --no-autoupdate
+            - --loglevel
+            - {{ .Values.cloudflared.loglevel | quote }}
+            - --metrics
+            - "0.0.0.0:{{ .Values.cloudflared.metricsPort }}"
+            - --url
+            - {{ .Values.cloudflared.url | default (printf "http://localhost:%v" .Values.service.targetPort) | quote }}
+            {{- range .Values.cloudflared.extraArgs }}
+            - {{ . | quote }}
+            {{- end }}
+          ports:
+            - name: cf-metrics
+              containerPort: {{ .Values.cloudflared.metricsPort }}
+              protocol: TCP
+          livenessProbe:
+            httpGet:
+              path: /ready
+              port: cf-metrics
+            failureThreshold: 3
+            initialDelaySeconds: 10
+            periodSeconds: 10
+          resources:
+            {{- toYaml .Values.cloudflared.resources | nindent 12 }}
+        {{- end }}
       volumes:
         - name: mn-campsite-data
           {{- if .Values.persistence.enabled }}
diff --git a/helm/mncampsiteapp/values.yaml b/helm/mncampsiteapp/values.yaml
index 6f56bc3..51135db 100644
--- a/helm/mncampsiteapp/values.yaml
+++ b/helm/mncampsiteapp/values.yaml
@@ -131,3 +131,27 @@ resources:
 nodeSelector: {}
 tolerations: []
 affinity: {}
+
+# ----------------------------------------------------------------------------
+# Cloudflare Quick Tunnel Sidecar Configuration (Ephemeral *.trycloudflare.com)
+# ----------------------------------------------------------------------------
+cloudflared:
+  enabled: true
+  image:
+    repository: cloudflare/cloudflared
+    tag: "latest"
+    pullPolicy: IfNotPresent
+  # Target URL to tunnel to. Defaults to http://localhost:<service.targetPort> (8000)
+  url: ""
+  loglevel: info
+  metricsPort: 2000
+  # Optional additional arguments to pass to `cloudflared tunnel`
+  extraArgs: []
+  resources:
+    requests:
+      cpu: 50m
+      memory: 64Mi
+    limits:
+      cpu: 200m
+      memory: 128Mi
+

I’ll now upgrade my app

(base) builder@LuiGi:~/Workspaces/mncampsiteapp$ helm upgrade --install mncampsite -f myvalues.yaml ./helm/mncampsiteapp
Release "mncampsite" has been upgraded. Happy Helming!
NAME: mncampsite
LAST DEPLOYED: Thu Oct 15 19:24:12 2026
NAMESPACE: default
STATUS: deployed
REVISION: 2
DESCRIPTION: Upgrade complete
TEST SUITE: None
NOTES:
1. Minnesota Park & Campsite Explorer has been deployed!
2. Application URL(s):
  https://camping.tpk.pw/

3. Ensure your Google Cloud Console OAuth 2.0 Client includes:
   - Authorized JavaScript origin: https://camping.tpk.pw
   - Authorized redirect URI:      https://camping.tpk.pw/auth/callback

Cloudflare Ephemeral Quick Tunnel is enabled!
To retrieve your generated *.trycloudflare.com URL, run:
  kubectl --namespace default logs deploy/mncampsite-mncampsiteapp -c cloudflared | grep trycloudflare.com
(base) builder@LuiGi:~/Workspaces/mncampsiteapp$ kubectl get po | grep -i camp
mncampsite-mncampsiteapp-5fd94f99b6-9ztzb             2/2     Running            0                  26s

I’ll follow the steps in the output to get the URL

$ kubectl --namespace default logs deploy/mncampsite-mncampsiteapp -c cloudflared | grep trycloudflare.com
2026-10-16T00:24:15Z INF Requesting new quick Tunnel on trycloudflare.com...
2026-10-16T00:24:19Z INF |  https://ethnic-forecast-post-jean.trycloudflare.com

It didn’t seem to work (and I confirmed the URL in the raw logs)

/img/2026-10-cloudflare-24.png

i tried a few approaches. I fired an Ubuntu temporary pod to ensure the Kube DNS was working and then tried updating the deployment’s cloudflared container to the simplest form:

- command:
  - cloudflared
  - tunnel
  - --url
  - http://mncampsite-mncampsiteapp/

I know that URL will work in an Ubuntu pod just fine.

But the updated URL

(base) builder@LuiGi:~/Workspaces/mncampsiteapp$ kubectl get po | grep camp
mncampsite-mncampsiteapp-6b5cc79c7f-j5ggz             2/2     Running            2 (20s ago)         90s
(base) builder@LuiGi:~/Workspaces/mncampsiteapp$ kubectl --namespace default logs deploy/mncampsite-mncampsiteapp -c cloudflared | grep trycloudflare.com
2026-10-16T00:50:32Z INF Requesting new quick Tunnel on trycloudflare.com...
2026-10-16T00:50:35Z INF |  https://steady-instrumental-you-promotion.trycloudflare.com

/img/2026-10-cloudflare-25.png

AHHHH I feel dumb*

after a bunch of debugging, and asking agy specific questions about the approach it figured it out. a fresh helm to restore what was in the chart worked

/img/2026-10-cloudflare-26.png

Like many times, I’m writing in a middle school swimming pool while my youngest is in synchro practice and the schools WiFi filters were throwing me off!!

/img/2026-10-cloudflare-27.png

So the meat of the work is really a deployment that just redirects to localhost:(pod ip):

$ kubectl get deployment mncampsite-mncampsiteapp -o yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  annotations:
    deployment.kubernetes.io/revision: "1"
    meta.helm.sh/release-name: mncampsite
    meta.helm.sh/release-namespace: default
  creationTimestamp: "2026-10-16T01:08:34Z"
  generation: 1
  labels:
    app.kubernetes.io/instance: mncampsite
    app.kubernetes.io/managed-by: Helm
    app.kubernetes.io/name: mncampsiteapp
    app.kubernetes.io/version: 1.0.0
    helm.sh/chart: mncampsiteapp-0.1.1
  name: mncampsite-mncampsiteapp
  namespace: default
  resourceVersion: "305770925"
  uid: a4c066df-36ea-41ab-98f6-2b1b34b314fe
spec:
  progressDeadlineSeconds: 600
  replicas: 1
  revisionHistoryLimit: 10
  selector:
    matchLabels:
      app.kubernetes.io/instance: mncampsite
      app.kubernetes.io/name: mncampsiteapp
  strategy:
    rollingUpdate:
      maxSurge: 25%
      maxUnavailable: 25%
    type: RollingUpdate
  template:
    metadata:
      annotations:
        checksum/config: 9ec7f20d7bdb34521500578232b6740348b8e58f2dc8d5a941438846a48dda1c
        checksum/secret: fa9f865fa6c8c97765e38d2de89e7d72ef4ed361a83cd2e2f976a2a03f0c0e08
      creationTimestamp: null
      labels:
        app.kubernetes.io/instance: mncampsite
        app.kubernetes.io/name: mncampsiteapp
    spec:
      containers:
      - envFrom:
        - configMapRef:
            name: mncampsite-mncampsiteapp-config
        - secretRef:
            name: mncampsite-mncampsiteapp-secret
        image: harbor.freshbrewed.science/library/mncamping:latest
        imagePullPolicy: IfNotPresent
        livenessProbe:
          failureThreshold: 3
          httpGet:
            path: /api/health
            port: http
            scheme: HTTP
          initialDelaySeconds: 15
          periodSeconds: 30
          successThreshold: 1
          timeoutSeconds: 10
        name: mncampsiteapp
        ports:
        - containerPort: 8000
          name: http
          protocol: TCP
        readinessProbe:
          failureThreshold: 3
          httpGet:
            path: /api/health
            port: http
            scheme: HTTP
          initialDelaySeconds: 10
          periodSeconds: 15
          successThreshold: 1
          timeoutSeconds: 5
        resources:
          limits:
            cpu: 500m
            memory: 512Mi
          requests:
            cpu: 100m
            memory: 256Mi
        securityContext:
          runAsGroup: 1000
          runAsNonRoot: true
          runAsUser: 1000
        terminationMessagePath: /dev/termination-log
        terminationMessagePolicy: File
        volumeMounts:
        - mountPath: /app/data
          name: mn-campsite-data
      - command:
        - cloudflared
        - tunnel
        - --no-autoupdate
        - --loglevel
        - info
        - --metrics
        - 0.0.0.0:2000
        - --url
        - http://localhost:8000
        image: cloudflare/cloudflared:latest
        imagePullPolicy: IfNotPresent
        livenessProbe:
          failureThreshold: 3
          httpGet:
            path: /ready
            port: cf-metrics
            scheme: HTTP
          initialDelaySeconds: 10
          periodSeconds: 10
          successThreshold: 1
          timeoutSeconds: 1
        name: cloudflared
        ports:
        - containerPort: 2000
          name: cf-metrics
          protocol: TCP
        resources:
          limits:
            cpu: 200m
            memory: 128Mi
          requests:
            cpu: 50m
            memory: 64Mi
        terminationMessagePath: /dev/termination-log
        terminationMessagePolicy: File
      dnsPolicy: ClusterFirst
      restartPolicy: Always
      schedulerName: default-scheduler
      securityContext:
        fsGroup: 1000
      terminationGracePeriodSeconds: 30
      volumes:
      - name: mn-campsite-data
        persistentVolumeClaim:
          claimName: mncampsite-mncampsiteapp-data
status:
  availableReplicas: 1
  conditions:
  - lastTransitionTime: "2026-10-16T01:08:52Z"
    lastUpdateTime: "2026-10-16T01:08:52Z"
    message: Deployment has minimum availability.
    reason: MinimumReplicasAvailable
    status: "True"
    type: Available
  - lastTransitionTime: "2026-10-16T01:08:34Z"
    lastUpdateTime: "2026-10-16T01:08:52Z"
    message: ReplicaSet "mncampsite-mncampsiteapp-5fd94f99b6" has successfully progressed.
    reason: NewReplicaSetAvailable
    status: "True"
    type: Progressing
  observedGeneration: 1
  readyReplicas: 1
  replicas: 1
  updatedReplicas: 1

Sure, the name will change on every pod deployment, but who cares - its for testing?

I need not get a DNS name for every idea I have and i could bake this into charts!

NGrok

Let’s just compare the containerized approach with Ngrok

I’ll just add the repo

curl -sSL https://ngrok-agent.s3.amazonaws.com/ngrok.asc | sudo tee /etc/apt/trusted.gpg.d/ngrok.asc >/dev/null
echo "deb https://ngrok-agent.s3.amazonaws.com bookworm main" | sudo tee /etc/apt/sources.list.d/ngrok.list

Then we can install

$ sudo apt update
$ sudo apt install ngrok

I was worried that the steps mention Debian bookworm and this laptop is running Ubuntu Resolute, but it installed

$ sudo apt install ngrok
The following packages were automatically installed and are no longer required:
  cargo-1.93  libgit2-1.9  libstd-rust-1.93  libstd-rust-1.93-dev  python3-pip-whl  python3-setuptools-whl  rustc-1.93
Use 'sudo apt autoremove' to remove them.

Installing:
  ngrok

Summary:
  Upgrading: 0, Installing: 1, Removing: 0, Not Upgrading: 34
  Download size: 8,707 kB
  Space needed: 0 B / 648 GB available

Get:1 https://ngrok-agent.s3.amazonaws.com bookworm/main amd64 ngrok amd64 3.39.11 [8,707 kB]
Fetched 8,707 kB in 2s (5,648 kB/s)
Selecting previously unselected package ngrok.
(Reading database… 360083 files and directories currently installed.)
Preparing to unpack …/ngrok_3.39.11_amd64.deb…
Unpacking ngrok (3.39.11)…
Setting up ngrok (3.39.11)…

I fired up an ngrok CLI

$ ngrok http http://localhost:8080
ERROR:  authentication failed: This ngrok session is not authenticated. ngrok requires an account and a valid credential to start a session.
ERROR:
ERROR:  Sign up for an account: https://dashboard.ngrok.com/signup
ERROR:  Get your credential: https://dashboard.ngrok.com/get-started/your-authtoken
ERROR:
ERROR:  ERR_NGROK_4018
ERROR:  https://ngrok.com/docs/errors/err_ngrok_4018
ERROR:

Ah, seems we cannot do anonymous.

Let’s signup

/img/2026-10-ngrok-01.png

I see my account has MFA

/img/2026-10-ngrok-02.png

I can login

/img/2026-10-ngrok-03.png

And get the AuthToken

/img/2026-10-ngrok-04.png

Once I pass in the auth token, ngrok CLI fires up

/img/2026-10-ngrok-05.png

And I can reach my local app

/img/2026-10-ngrok-06.png

We can see Ngrok has some limits

/img/2026-10-ngrok-07.png

The paid plan is pretty reasonable

/img/2026-10-ngrok-08.png

Kubernetes

I can base the ngrok tunnel off the one we did for Cloudflare.

$ git diff
diff --git a/helm/mncampsiteapp/Chart.yaml b/helm/mncampsiteapp/Chart.yaml
index 1c2a543..6955bad 100644
--- a/helm/mncampsiteapp/Chart.yaml
+++ b/helm/mncampsiteapp/Chart.yaml
@@ -2,7 +2,7 @@ apiVersion: v2
 name: mncampsiteapp
 description: A Helm chart for deploying the Minnesota Park & Campsite Explorer FastAPI application on Kubernetes
 type: application
-version: 0.1.1
+version: 0.1.2
 appVersion: "1.0.0"
 keywords:
   - minnesota
diff --git a/helm/mncampsiteapp/templates/NOTES.txt b/helm/mncampsiteapp/templates/NOTES.txt
index d3b5f8a..bdd9b19 100644
--- a/helm/mncampsiteapp/templates/NOTES.txt
+++ b/helm/mncampsiteapp/templates/NOTES.txt
@@ -15,10 +15,9 @@
 2. Port-forward to test locally:
    kubectl --namespace {{ .Release.Namespace }} port-forward svc/{{ include "mncampsiteapp.fullname" . }} 8000:{{ .Values.service.port }}
 {{- end }}
-{{- if .Values.cloudflared.enabled }}
+{{- if .Values.ngrok.enabled }}

-Cloudflare Ephemeral Quick Tunnel is enabled!
-To retrieve your generated *.trycloudflare.com URL, run:
-  kubectl --namespace {{ .Release.Namespace }} logs deploy/{{ include "mncampsiteapp.fullname" . }} -c cloudflared | grep trycloudflare.com
+ngrok Tunnel is enabled!
+To retrieve your generated ngrok URL, run:
+  kubectl --namespace {{ .Release.Namespace }} logs deploy/{{ include "mncampsiteapp.fullname" . }} -c ngrok | grep "url="
 {{- end }}
-
diff --git a/helm/mncampsiteapp/templates/deployment.yaml b/helm/mncampsiteapp/templates/deployment.yaml
index df6f719..339d466 100644
--- a/helm/mncampsiteapp/templates/deployment.yaml
+++ b/helm/mncampsiteapp/templates/deployment.yaml
@@ -52,36 +52,25 @@ spec:
           volumeMounts:
             - name: mn-campsite-data
               mountPath: {{ .Values.persistence.mountPath }}
-        {{- if .Values.cloudflared.enabled }}
-        - name: cloudflared
-          image: "{{ .Values.cloudflared.image.repository }}:{{ .Values.cloudflared.image.tag }}"
-          imagePullPolicy: {{ .Values.cloudflared.image.pullPolicy }}
+        {{- if .Values.ngrok.enabled }}
+        - name: ngrok
+          image: "{{ .Values.ngrok.image.repository }}:{{ .Values.ngrok.image.tag }}"
+          imagePullPolicy: {{ .Values.ngrok.image.pullPolicy }}
           command:
-            - cloudflared
-            - tunnel
-            - --no-autoupdate
-            - --loglevel
-            - {{ .Values.cloudflared.loglevel | quote }}
-            - --metrics
-            - "0.0.0.0:{{ .Values.cloudflared.metricsPort }}"
-            - --url
-            - {{ .Values.cloudflared.url | default (printf "http://localhost:%v" .Values.service.targetPort) | quote }}
-            {{- range .Values.cloudflared.extraArgs }}
+            - ngrok
+            - http
+            - {{ .Values.ngrok.url | default (printf "http://localhost:%v" .Values.service.targetPort) | quote }}
+            - --authtoken
+            - {{ .Values.ngrok.authtoken | quote }}
+            {{- if .Values.ngrok.log }}
+            - --log
+            - {{ .Values.ngrok.log | quote }}
+            {{- end }}
+            {{- range .Values.ngrok.extraArgs }}
             - {{ . | quote }}
             {{- end }}
-          ports:
-            - name: cf-metrics
-              containerPort: {{ .Values.cloudflared.metricsPort }}
-              protocol: TCP
-          livenessProbe:
-            httpGet:
-              path: /ready
-              port: cf-metrics
-            failureThreshold: 3
-            initialDelaySeconds: 10
-            periodSeconds: 10
           resources:
-            {{- toYaml .Values.cloudflared.resources | nindent 12 }}
+            {{- toYaml .Values.ngrok.resources | nindent 12 }}
         {{- end }}
       volumes:
         - name: mn-campsite-data
diff --git a/helm/mncampsiteapp/values.yaml b/helm/mncampsiteapp/values.yaml
index 51135db..e1c444e 100644
--- a/helm/mncampsiteapp/values.yaml
+++ b/helm/mncampsiteapp/values.yaml
@@ -133,19 +133,19 @@ tolerations: []
 affinity: {}

 # ----------------------------------------------------------------------------
-# Cloudflare Quick Tunnel Sidecar Configuration (Ephemeral *.trycloudflare.com)
+# ngrok Tunnel Sidecar Configuration
 # ----------------------------------------------------------------------------
-cloudflared:
+ngrok:
   enabled: true
   image:
-    repository: cloudflare/cloudflared
+    repository: ngrok/ngrok
     tag: "latest"
     pullPolicy: IfNotPresent
   # Target URL to tunnel to. Defaults to http://localhost:<service.targetPort> (8000)
   url: ""
-  loglevel: info
-  metricsPort: 2000
-  # Optional additional arguments to pass to `cloudflared tunnel`
+  authtoken: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
+  log: "stdout"
+  # Optional additional arguments to pass to `ngrok http`
   extraArgs: []
   resources:
     requests:
@@ -154,4 +154,3 @@ cloudflared:
     limits:
       cpu: 200m
       memory: 128Mi
-

Now I just helm upgrade

$ !2046
helm upgrade mncampsite ./helm/mncampsiteapp -f myvalues.yaml
Release "mncampsite" has been upgraded. Happy Helming!
NAME: mncampsite
LAST DEPLOYED: Fri Oct 16 06:07:44 2026
NAMESPACE: default
STATUS: deployed
REVISION: 5
DESCRIPTION: Upgrade complete
TEST SUITE: None
NOTES:
1. Minnesota Park & Campsite Explorer has been deployed!
2. Application URL(s):
  https://camping.tpk.pw/

3. Ensure your Google Cloud Console OAuth 2.0 Client includes:
   - Authorized JavaScript origin: https://camping.tpk.pw
   - Authorized redirect URI:      https://camping.tpk.pw/auth/callback

ngrok Tunnel is enabled!
To retrieve your generated ngrok URL, run:
  kubectl --namespace default logs deploy/mncampsite-mncampsiteapp -c ngrok | grep "url="

Once the pods are up, I’ll get the ngrok URL

(base) builder@LuiGi:~/Workspaces/mncampsiteapp$ kubectl get po | grep camp
mncampsite-mncampsiteapp-5d7b77944b-tbh2m             2/2     Running            0                   2m34s
(base) builder@LuiGi:~/Workspaces/mncampsiteapp$ kubectl --namespace default logs deploy/mncampsite-mncampsiteapp -c ngrok | grep "url="
t=2026-10-16T11:07:53+0000 lvl=info msg="started tunnel" obj=tunnels name=command_line addr=http://localhost:8000 url=https://e486-76-156-69-232.ngrok-free.app
(base) builder@LuiGi:~/Workspaces/mncampsiteapp$

And indeed it worked just fine

/img/2026-10-cloudflare-28.png

We can also just check the ngrok logs on the container

$ kubectl logs mncampsite-mncampsiteapp-5d7b77944b-tbh2m -c ngrok
t=2026-10-16T11:07:53+0000 lvl=info msg="no configuration paths supplied"
t=2026-10-16T11:07:53+0000 lvl=info msg="ignoring default config path" path=/home/ngrok/.config/ngrok/ngrok.yml err="stat /home/ngrok/.config/ngrok/ngrok.yml: no such file or directory"
t=2026-10-16T11:07:53+0000 lvl=info msg="FIPS 140 mode" enabled=false
t=2026-10-16T11:07:53+0000 lvl=info msg="starting web service" obj=web addr=127.0.0.1:4040 allow_hosts=[]
t=2026-10-16T11:07:53+0000 lvl=info msg="client session established" obj=tunnels.session
t=2026-10-16T11:07:53+0000 lvl=info msg="tunnel session started" obj=tunnels.session
t=2026-10-16T11:07:53+0000 lvl=info msg="update available" obj=updater
t=2026-10-16T11:07:53+0000 lvl=info msg="started tunnel" obj=tunnels name=command_line addr=http://localhost:8000 url=https://e486-76-156-69-232.ngrok-free.app
t=2026-10-16T11:11:05+0000 lvl=info msg="join connections" obj=join id=76ac30166c71 l=127.0.0.1:8000 r=76.156.69.232:34586
t=2026-10-16T11:11:06+0000 lvl=info msg="join connections" obj=join id=82d28e5a01b2 l=127.0.0.1:8000 r=76.156.69.232:34586
t=2026-10-16T11:11:07+0000 lvl=info msg="join connections" obj=join id=e057a0311b5e l=127.0.0.1:8000 r=76.156.69.232:34586

Summary

The goal today was to look into Cloudflare Tunnels and see what they could do. We wanted to set them up locally then compare the offering to ngrok tunnels.

I also wanted to see how hard they would be to add to a simple Kubernetes deployment. I used Antigravity to help tweak those up with the only issue coming due to WiFi filters blocking testing where I was at the time.

Both companies have paid tiers. Cloudflare pricing shows they really give a lot away in the free tier. If you dig into Ngrok pricing you’ll find a lot more limits in the free tier. The companies though are quite different in size. NGrok has about $50m in funding since 2015 and about 80 employees whereas Cloudflare pulls in about $2.2 billion a year and has 5200 or so employees.

I knew of NGrok from back at AWS Re:Invent 2016 where it was used heavily in the on-site demos. Cloudflare I think everyone knows about.

Regardless, both tunneling solutions are pretty slick and if you are a small hobbyist like myself, I might argue Ngrok has the nicer paid plan at $10/mo (“Hobbyist”) compared to the lowest Cloudflare plan at $20/mo (“Pro”). However, Cloudflare has a lot more to offer and certainly can scale with traffic.