Le problème : accès distant sans port forwarding
Je voulais pouvoir lancer une démo ArgoCD depuis n’importe où, ou dépanner le cluster depuis mon téléphone hors du LAN — sans ouvrir un port sur ma box résidentielle ni exposer une IP dynamique à tout Internet. Les solutions classiques ont des inconvénients :
- Port forwarding sur le routeur : expose directement des ports sur une IP résidentielle. Risque sécuritaire, IP dynamique, pas de certificat automatique.
- VPN : sécurisé, mais lourd à configurer sur chaque appareil client.
- Tunnel Cloudflare : une seule connexion sortante depuis le cluster vers Cloudflare. Aucun port entrant ouvert. Cloudflare gère le TLS, le DDoS protection, et le routage DNS.
Le tunnel Cloudflare est la solution retenue.
Architecture
Internet
│
▼
Cloudflare Edge (TLS terminé, DDoS protégé)
│
│ Tunnel chiffré (outbound depuis le cluster)
▼
cloudflared (pod dans le cluster)
│
│ HTTP interne port 80
▼
Traefik (Ingress Controller)
│
│ Routing par hostname
├──► gitea.olig-homelab-2025.win → Service gitea:3000
├──► code.olig-homelab-2025.win → Service code-server:8080
├──► claude.olig-homelab-2025.win → Service claude-webui:8080
└──► ...
Cloudflare termine le TLS côté internet. La connexion entre Cloudflare et le cluster est chiffrée via le tunnel. À l’intérieur du cluster, tout transite en HTTP sur le réseau interne Kubernetes — acceptable pour ce modèle de menace.
HTTP :80 en interne.
Déploiement de cloudflared
# cloudflared/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: cloudflared
namespace: cloudflared
spec:
replicas: 2
template:
spec:
containers:
- name: cloudflared
image: cloudflare/cloudflared:latest
args:
- tunnel
- --config
- /etc/cloudflared/config.yaml
- run
volumeMounts:
- name: config
mountPath: /etc/cloudflared
readOnly: true
- name: creds
mountPath: /etc/cloudflared/creds
readOnly: true
volumes:
- name: config
configMap:
name: cloudflared-config
- name: creds
secret:
secretName: cloudflared-credentials
Le déploiement utilise 2 répliques pour la redondance — si un pod cloudflared crash, l’autre maintient le tunnel.
Configuration du tunnel
# ConfigMap cloudflared-config
tunnel: <TUNNEL_ID>
credentials-file: /etc/cloudflared/creds/credentials.json
ingress:
- hostname: "*.olig-homelab-2025.win"
service: http://traefik.kube-system.svc.cluster.local:80
- service: http_status:404
Le wildcard *.olig-homelab-2025.win route tout le trafic vers Traefik. Traefik se charge ensuite du routing par Host header vers le bon service.
Traefik comme Ingress Controller
Traefik v3.5.1 est installé via le mécanisme HelmChart de k3s (--disable traefik au bootstrap désactive le bundle par défaut, puis une ressource HelmChart CRD déploie la version Helm avec la configuration personnalisée). Le ServiceLB de k3s reste actif et gère le service LoadBalancer de Traefik — c’est ce qui crée les pods svclb-traefik-* sur chaque nœud.
Point clé : Cloudflare Tunnel route vers le port 80 (entrypoint web) de Traefik, pas le port 443. Le TLS est terminé par Cloudflare — Traefik n’a pas besoin de gérer les certificats.
Cela a une implication importante sur les Ingress : ne pas ajouter l’annotation websecure sur les ressources Ingress. Une erreur fréquente qui entraîne des 404 page not found — Traefik attend le trafic sur le port 443, mais il arrive sur le port 80.
# ✅ Correct — pas d'annotation websecure
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: gitea
namespace: gitea
spec:
ingressClassName: traefik
rules:
- host: gitea.olig-homelab-2025.win
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: gitea
port:
number: 3000
# ❌ Incorrect — websecure force le port 443
metadata:
annotations:
traefik.ingress.kubernetes.io/router.entrypoints: websecure
DNS Cloudflare
Dans le dashboard Cloudflare :
- Créer un tunnel via “Zero Trust → Access → Tunnels”
- Ajouter un enregistrement CNAME dans le DNS :
*.olig-homelab-2025.win → <tunnel-id>.cfargotunnel.com - Le wildcard CNAME couvre tous les sous-domaines sans configuration DNS individuelle
Proxy activé (orange cloud) : le trafic transite par Cloudflare avant d’arriver au tunnel. Protection DDoS, cache, analytics inclus.
Sécurité additionnelle avec Cloudflare Access
Tous les sous-domaines publics passent par Cloudflare Access (SSO). Pour les UI les plus sensibles (ArgoCD, Longhorn, Grafana), c’est une ligne de défense non négociable : Cloudflare Zero Trust Access ajoute une couche d’authentification devant le tunnel :
Requête vers argocd.olig-homelab-2025.win
│
▼
Cloudflare Access (vérification identité)
│ - Login avec email autorisé (allowlist Cloudflare Access)
│ - Token JWT injecté dans la requête
▼
Tunnel → Traefik → ArgoCD UI
Aucun middleware Kubernetes n’est nécessaire — l’authentification est gérée au niveau Cloudflare, avant que la requête atteigne le cluster.
Mise à jour 2026-05-17 — Cloudflared adopté par ArgoCD
Contexte
cloudflared tournait dans le cluster en tant que Deployment créé à la main au tout début du homelab, hors GitOps. Un workload critique — sans lui, plus aucun accès externe — mais invisible dans le repo. Il pouvait dériver silencieusement : image non patchée, spec divergente de ce qu’on croyait avoir. Pas idéal.
Deux commits ont remedié ça :
e345ac8 chore(cloudflared): bump 2026.3.0 -> 2026.5.0162bde3 feat(cloudflared): manage via ArgoCD (adopt existing deployment)
L’opération en détail
1. Bump préalable vers 2026.5.0
Avant d’attacher ArgoCD, mise à jour de l’image vers cloudflare/cloudflared:2026.5.0 et vérification que la nouvelle version tourne bien — tunnel actif, connexions Cloudflare confirmées. Inutile de compliquer l’adoption en changeant l’image et le mode de gestion en même temps.
2. Création du dossier cloudflared/ dans le repo
cloudflared/
├── 00-namespace.yaml
├── deployment.yaml # 2 replicas, anti-affinity inter-nodes
├── sealedsecret-creds.yaml # token tunnel scellé via Sealed Secrets
└── configmap.yaml # hostname → service mapping
Le deployment.yaml canonique ajoute une règle d’anti-affinity pour distribuer les deux répliques sur des nœuds différents — ce qui n’était pas le cas du Deployment initial :
affinity:
podAntiAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchLabels:
app: cloudflared
topologyKey: kubernetes.io/hostname
3. Adoption sans interruption
Adopter un workload existant dans GitOps n’est pas la même chose que créer un nouveau workload via GitOps. La subtilité : au premier sync, ArgoCD compare le manifest déclaré avec l’état runtime. La moindre différence déclenche un rollout — ce qu’on veut absolument éviter sur un composant aussi critique.
Procédure suivie :
# Capturer l'état actuel
kubectl get deploy cloudflared -n cloudflared -o yaml > current.yaml
# Épurer les champs auto-générés
# Supprimer : metadata.{uid,resourceVersion,creationTimestamp,generation}, status
# Valider le diff côté serveur avant de créer l'Application ArgoCD
kubectl diff -f cloudflared/deployment.yaml --server-side
L’objectif : zéro diff entre le YAML déclaré et l’état runtime avant le premier sync. Un label injecté par un controller, un default Kubernetes silencieux — n’importe quelle divergence suffit à déclencher un rollout non désiré. --dry-run=server -o yaml expose ces diffs invisibles.
# Application ArgoCD avec prune désactivé par précaution
syncPolicy:
automated:
prune: false # ne pas supprimer de ressources au premier sync
selfHeal: true
4. Validation
- Aucun rolling update au premier sync : diff vide confirmé
- 2/2 pods
Ready - 4 connexions actives vers les datacenters Cloudflare (
fra,zrh) confirmées viacloudflared tunnel info
Leçons apprises
http_status:404 à la fin : toujours terminer la config cloudflared avec un fallback 404. Sans lui, cloudflared renvoie une erreur 502 opaque pour les hostnames non configurés.
2 répliques cloudflared minimum : avec une seule réplique, un redémarrage de pod (mise à jour, OOM kill) interrompt tous les accès externes pendant ~30 secondes. Avec 2 répliques, le failover est transparent.
Timeouts WebSocket : code-server utilise WebSockets pour l’éditeur. Configurer le timeout WebSocket Cloudflare (Tunnel → Settings) à une valeur haute (1 heure) pour éviter les déconnexions en plein travail.
Mise à jour 2026-06 — Le tunnel a ses pièges
Deux leçons apprises à la dure depuis la rédaction de cet article :
QUIC n’est pas garanti sur tous les réseaux. Sur ma connexion, le data path UDP 7844 est dégradé quelque part dans la box — les connexions QUIC s’enregistrent mais l’edge ne route aucune requête. Si vos 502 coïncident avec un passage à QUIC, testez --protocol http2 avant de chercher plus loin (et inversement).
Les connecteurs d’un tunnel sont interchangeables — et c’est une bombe si l’un d’eux est un fantôme. Un cloudflared oublié sur un appareil hors cluster, enregistré sur le tunnel du cluster, m’a coûté quatre jours de 502 intermittents. L’histoire complète, fausses pistes incluses : Le connecteur fantôme.