Hosting Apps Outside Flux
How to deploy a workload directly into the anton cluster with kubectl apply, bypassing the GitOps repo, while still reusing the existing Cloudflare Tunnel, wildcard certificate, and envoy-external gateway. Useful for short-lived experiments, throwaway demos, or one-off services that don't justify a full Flux scaffold.
When to use this pattern
| Use this pattern | Use Flux (the GitOps repo) |
|---|---|
| Throwaway prototype you'll delete in days | Anything you want to survive a cluster reset |
| Manual hands-on iteration loop (image-tag changes, debug builds) | Anything other people in the cluster need to rely on |
| You need a hostname now and the GitOps round-trip is too slow | Production-quality services with backups, monitoring, alerting |
| Demos that should disappear when the laptop closes | Anything you would be unhappy to lose |
If a "throwaway prototype" turns into something you're glad you have, promote it into Flux via the add-flux-app skill. The HTTPRoute and Service shapes carry over identically; only the Deployment graduates into a HelmRelease (or stays as raw manifests under app/kustomization.yaml).
Why this works
Three load-bearing facts about the cluster make this pattern viable without touching a single line of kubernetes/:
- Wildcard tunnel ingress.
cloudflare-tunnel's ConfigMap routes*.<onboarded-domain>toenvoy-external. Any new subdomain of an already-onboarded domain is covered automatically. - Wildcard TLS cert. Each onboarded domain has a
*.<domain>certificate attached to theenvoy-externalgateway listener. Every new subdomain inherits TLS for free. - Cross-namespace gateway attach. The
envoy-externallistener is configured withallowedRoutes.namespaces.from: All, so anHTTPRoutein any namespace — including a non-Flux-managed one — can attach to it.
The only thing the cluster can't do for you on the cheap path is publish DNS, because external-dns runs with --gateway-name=envoy-external (which forces it to inherit the gateway's annotation and ignore HTTPRoute-level annotations). For non-primary domains, you must include a DNSEndpoint resource — exactly the same gotcha as for any non-primary domain in the GitOps flow. See adding a 2nd domain for the underlying explanation.
Namespace strategy
Use a dedicated scratch (or lab, or whatever name resonates) namespace that no Flux Kustomization owns. Benefits:
- Zero pruning risk. Flux only prunes resources carrying its ownership labels (
kustomize.toolkit.fluxcd.io/*); imperatively-applied resources don't have them. A namespace with no Flux Kustomization removes the question entirely. - One-command teardown:
kubectl delete ns scratchwipes every experiment in there. - Defense-in-depth: annotating the namespace with
kustomize.toolkit.fluxcd.io/prune: disabledensures even a future Flux Kustomization pointed at this namespace cannot delete it.
Avoid putting these resources into Flux-managed namespaces (default, network, observability, databases, etc.). It works, because Flux still won't prune your unlabelled resources, but raises the chance of name collisions and confusing debug sessions later.
Minimum manifest set
Five resources in one YAML file. Save outside the anton repo (e.g. ~/scratch/<app>.yaml). Substitute the app name (hello), the public hostname (hello.example.com), and the container image as needed. The external.example.com target in the DNSEndpoint matches the existing tunnel-CNAME entry that cloudflare-tunnel/app/dnsendpoint.yaml already publishes for the onboarded domain.
---
apiVersion: v1
kind: Namespace
metadata:
name: scratch
annotations:
kustomize.toolkit.fluxcd.io/prune: disabled
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: hello
namespace: scratch
labels:
scratch.app: hello
spec:
replicas: 1
selector:
matchLabels:
scratch.app: hello
template:
metadata:
labels:
scratch.app: hello
spec:
containers:
- name: app
image: ghcr.io/mendhak/http-https-echo:38
env:
- { name: HTTP_PORT, value: "80" }
ports:
- { containerPort: 80, name: http }
resources:
requests: { cpu: 10m, memory: 32Mi }
limits: { memory: 128Mi }
---
apiVersion: v1
kind: Service
metadata:
name: hello
namespace: scratch
labels:
scratch.app: hello
spec:
selector:
scratch.app: hello
ports:
- { name: http, port: 80, targetPort: http }
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: hello
namespace: scratch
labels:
scratch.app: hello
spec:
parentRefs:
- name: envoy-external
namespace: network
sectionName: https
hostnames:
- hello.example.com
rules:
- backendRefs:
- name: hello
port: 80
---
apiVersion: externaldns.k8s.io/v1alpha1
kind: DNSEndpoint
metadata:
name: hello
namespace: scratch
labels:
scratch.app: hello
spec:
endpoints:
- dnsName: hello.example.com
recordType: CNAME
targets: ["external.example.com"]
A few details worth understanding:
- The
scratch.app: <name>label on every resource lets you target a single app for teardown without disturbing siblings sharing the namespace. parentRefs[].sectionName: httpsattaches to the gateway's TLS listener (port 443), not the plain HTTP-redirect listener.parentRefs[].namespace: networkis required — the gateway lives in thenetworknamespace, the route inscratch.- The
DNSEndpoint'stargetsfield references a CNAME target that the cluster's tunnelDNSEndpointresource has already published — that resolves the chain<app>.<domain>→external.<domain>→<tunnel-uuid>.cfargotunnel.com.
Pre-flight: check for stale DNS records
external-dns runs with policy: sync and identifies records it owns via a k8s.cname-<host> TXT marker. It refuses to overwrite foreign records (no marker), and the skip is logged at debug level only. This means a hostname that was previously bound to a different (now-decommissioned) tunnel can silently absorb your deploy and route requests into a black hole — Cloudflare error 1033.
Before you kubectl apply for a brand-new hostname, run:
TOKEN=$(kubectl -n network get secret cloudflare-dns-secret -o jsonpath='{.data.api-token}' | base64 -d)
ZONE_ID=$(curl -s -H "Authorization: Bearer $TOKEN" \
"https://api.cloudflare.com/client/v4/zones?name=example.com" | jq -r '.result[0].id')
curl -s -H "Authorization: Bearer $TOKEN" \
"https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records?name=hello.example.com" \
| jq '.result[] | {type, name, content, proxied}'
If non-empty and content is anything other than external.<your-domain>, you've found the conflict before it bites. Recovery and full triage in cloudflare-tunnel-dns-conflicts.md.
Workflow
# deploy
kubectl apply -f ~/scratch/hello.yaml
# verify (DNS publishes within ~1 min after the DNSEndpoint lands)
kubectl get dnsendpoint -n scratch
kubectl get httproute -n scratch hello -o yaml | grep -A 5 status:
dig +short hello.example.com @1.1.1.1
curl -sI https://hello.example.com/
# iterate
kubectl set image -n scratch deploy/hello app=<new-image>
kubectl rollout status -n scratch deploy/hello
# tear down a single app
kubectl delete -n scratch all,httproute,dnsendpoint -l scratch.app=hello
# nuke everything in scratch
kubectl delete ns scratch
What's bypassed vs reused
| Concern | Behavior |
|---|---|
| Flux ownership / pruning | Bypassed — the scratch namespace is invisible to Flux |
Cluster-secrets ${VAR} substitution | Bypassed — write literal hostnames directly |
| Tunnel ingress edits | Not needed — wildcard covers all subdomains of an onboarded domain |
| Wildcard cert | Reused — comes for free via the envoy-external listener |
| External-dns | Used — picks up the DNSEndpoint and publishes the CNAME |
envoy-external gateway | Used — HTTPRoute attaches cross-namespace |
| Cloudflare tunnel | Used — cloudflared proxies the request the same way it does for Flux-managed apps |
The pattern reuses the heavy stable bits (gateway, cert, tunnel) and skips the parts that exist for safe collaboration (GitOps round-trip, postBuild substitution, code review). That's the right trade for one-off work.
Things to know
- Image stability. No
imagePullPolicy: Alwaysis set, and the:38tag is immutable, so the pod will not pull a newer image on its own. That's intentional for stability between iterations. Force an update withkubectl set imageorkubectl rollout restart. - Audit drift periodically. Imperative resources are invisible to the repo, which means they're invisible to your future self. Once a quarter, run
kubectl get httproute -Aandkubectl get dnsendpoint -Aand confirm everything in non-Flux namespaces (e.g.scratch,lab) is intentional. - Promotion path. When something graduates from "experiment" to "real," scaffold it via
add-flux-app(10-minute round-trip). TheHTTPRouteshape carries over verbatim; only theDeploymentmoves into aHelmRelease. - Cleanup gotcha. If you
kubectl delete ns scratchwhile anHTTPRouteoutsidescratchreferences one of its Services (rare; only if you cross-pollinated namespaces deliberately), the route hangs inAccepted=Falseuntil you fix the dangling reference. Easy to spot viakubectl describe httproute. - No Prometheus scrape by default. kube-prometheus-stack scrapes via
ServiceMonitor/PodMonitordiscovery in known namespaces; your scratch app won't show up in Grafana unless you add aServiceMonitor. That's usually fine for experiments. - No backups, no replication, no DR. A
scratchDeployment is single-replica, no PVC by default, no Longhorn replication. If the workload writes state you care about, this is the wrong pattern — graduate to Flux first.
When the wildcard isn't enough
If the experimental hostname needs a domain the cluster has not yet onboarded, you can't avoid a Flux change — onboarding a new domain edits cluster-secrets, cloudflare-tunnel, envoy-gateway, cert-manager, and cloudflare-dns, all of which are GitOps territory. Walk-through: adding a 2nd domain. After the domain is onboarded, this pattern works for any subdomain of it.
Related notes
- Adding a 2nd domain — full domain onboarding flow; prerequisite for new top-level zones
- Cloudflare Tunnel DNS conflicts — what to do when the public hostname returns Cloudflare 1033 / HTTP 530 despite a healthy in-cluster deploy
- Exposing workloads through Tailscale — alternative path for off-LAN HTTP UIs without going public