Objectif 🎯
L’essentiel de l’infrastructure étant enfin finalisée, il ne reste plus qu’à mettre en place le déploiement continu (CD) avec Flux.
Pour rappel, Flux est un outil GitOps qui permet de synchroniser l’état d’un cluster Kubernetes avec un dépôt Git. Ainsi, toute modification dans le dépôt Git sera automatiquement appliquée au cluster Kubernetes.
Installation Flux
Créer votre repo git vide flux source sur GitHub ou tout autre VCS, l’URL ici sera https://github.com/ohmytalos/flux-source.git.
module "kube_delivery" { source = "../../modules/kube/delivery"
flux_git_repository = "https://github.com/ohmytalos/flux-source.git" flux_git_password = var.flux_git_password flux_bootstrap_git_path = "clusters/dev" flux_sops_age_secret_key = var.flux_sops_age_secret_key}Explanation
Cette configuration du module de delivery Kubernetes initialise Flux avec le dépôt Git spécifié. Le chemin clusters/dev indique où se trouvent les configurations spécifiques à ce cluster de développement.
Enfin, GitOps oblige, nous utiliserons sops avec une clé age pour chiffrer les secrets dans notre dépôt Git via la clé publique. Stocker la clé privée dans Bitwarden pour une utilisation par Flux au sein du cluster pour le déchiffrement.
// ...
variable "flux_git_password" { type = string sensitive = true}
variable "flux_sops_age_secret_key" { type = string sensitive = true}# ...
export TF_VAR_flux_git_password=$(bw_field password flux_git)export TF_VAR_flux_sops_age_secret_key=$(bw_field password flux_sops_age)Explanation
Pour obtenir la clé flux_git, aller créer un PAT nommé flux-dev, avec les droits d’accès uniquement au repo flux précédemment créée sur les metadatas en lecture puis lecture/écriture sur le contenu/code, puis récupérer la clé générée.
Utiliser age-keygen -o age.agekey pour générer une paire de clés age, puis stocker les dans votre Bitwarden sous l’item flux_sops_age.
variable "flux_git_repository" { description = "The flux git repository" type = string}
variable "flux_git_password" { description = "The password for the flux git repository" type = string sensitive = true}
variable "flux_bootstrap_git_path" { description = "The path within the git repository to bootstrap flux" type = string}
variable "flux_sops_age_secret_key" { description = "The SOPS age secret key for flux" type = string sensitive = true}terraform { required_providers { flux = { source = "fluxcd/flux" version = ">=1.0.0" } }}
provider "flux" { kubernetes = { config_path = "~/.kube/config" } git = { url = var.flux_git_repository branch = "main" http = { username = "git" password = var.flux_git_password } }}resource "kubernetes_namespace_v1" "flux_system" { metadata { name = "flux-system" }
lifecycle { ignore_changes = [ metadata[0].labels ] }}
resource "kubernetes_secret_v1" "flux_sops_age" { metadata { name = "sops-age" namespace = kubernetes_namespace_v1.flux_system.metadata[0].name } data = { "age.agekey" = var.flux_sops_age_secret_key }}
resource "flux_bootstrap_git" "this" { path = var.flux_bootstrap_git_path embedded_manifests = true
components_extra = [ "image-reflector-controller", "image-automation-controller" ]
kustomization_override = file("${path.module}/kustomization.yaml")
depends_on = [kubernetes_secret_v1.flux_sops_age]}apiVersion: kustomize.config.k8s.io/v1beta1kind: Kustomizationresources: - gotk-components.yaml - gotk-sync.yamlpatches: - patch: | - op: add path: /spec/decryption value: provider: sops secretRef: name: sops-age target: kind: Kustomization name: flux-system namespace: flux-systemExplanation
Le 1er élément important est la configuration du provider Flux, en précisant l’accès au dépôt Git et l’accès au cluster Kubernetes nécessaire pour le bootstrapping.
Ensuite on passe à la création du secret Kubernetes contenant la clé privée age.agekey pour SOPS, qui sera utilisé par Flux pour déchiffrer les secrets stockés dans le dépôt Git.
Puis on paramètre le bootstrap Git de Flux, en spécifiant le chemin dans le dépôt Git où se trouvent les configurations spécifiques au cluster. On ajoute également les contrôleurs d’automatisation d’images pour gérer les mises à jour automatiques des images conteneurisées. Puis on applique un patch pour configurer le déchiffrement SOPS en utilisant le secret Kubernetes créé précédemment.
Plus qu’à déployer avec terraform apply pour lancer le bootstrap de Flux dans le cluster Kubernetes. Une fois le processus terminé, Flux commencera à synchroniser l’état du cluster avec le dépôt Git. Lancer kgp -n flux-system pour vérifier l’état des 6 composants flux :
NAME READY STATUS RESTARTS AGEhelm-controller-58854b78db-x7jc9 1/1 Running 0 19simage-automation-controller-854587f458-zx5sz 1/1 Running 0 19simage-reflector-controller-745b9fffd6-6wjsb 1/1 Running 0 19skustomize-controller-cc4f4cbbf-tznk5 1/1 Running 0 19snotification-controller-c58679c45-jfd8l 1/1 Running 0 19ssource-controller-86968b9cb8-b9fz7 1/1 Running 0 18s| Nom | Tâche |
|---|---|
source-controller | Synchronise les sources Git et défini l’état courant de réconciliation. |
kustomize-controller | Applique les configurations Kustomize fournies par source. |
helm-controller | Déploie les releases Helm fournies par source. |
image-reflector-controller | Surveille les registres d’images pour les nouvelles versions. |
image-automation-controller | Automatise les mises à jour des images dans les manifests. |
notification-controller | Gère les notifications basées sur les événements dans Flux. |
Nous en avons enfin fini pour l’installation de l’infrastruture Kubernetes côté Terraform. La suite concernera la partie applicative géré par Flux, la 3ème brique de ce guide.
Monitoring Flux
La 1ère chose utile serait d’installer de quoi monitorer notre instance de flux. Pour cela, nous allons nous appuyer sur le repo flux-monitoring-example qui contient déjà tout ce qu’on veut.
---apiVersion: source.toolkit.fluxcd.io/v1kind: GitRepositorymetadata: name: flux-monitoring namespace: flux-systemspec: interval: 30m0s ref: branch: main url: https://github.com/fluxcd/flux2-monitoring-example---apiVersion: kustomize.toolkit.fluxcd.io/v1kind: Kustomizationmetadata: name: monitoring-config namespace: flux-systemspec: interval: 1h0m0s path: ./monitoring/configs prune: true sourceRef: kind: GitRepository name: flux-monitoringCommiter ce fichier et vous devriez observer le déploiement automatique de :
- Un PodMonitor, permettant de scrapper les métriques de flux via Prometheus, disponible via
k get pmon -n monitoring -l app.kubernetes.io/part-of=flux. - Des dashboards Grafana, contenant des dashboards préconfigurés pour visualiser les métriques de flux, disponibles via
k get cm -n monitoring -l app.kubernetes.io/part-of=flux.

Tip
Pour éviter d’attendre une minute et forcer la synchronisation de l’état avec votre dépôt Git, vous pouvez utiliser la commande flux reconcile kustomization flux-system --with-source.
Cas pratique avec n8n
Pour illustrer l’utilisation de Flux pour gérer les applications dans notre cluster Kubernetes, nous allons déployer n8n, l’outil de prédilection d’automatisation de flux de travail.
Storage n8n
Commencer par créer une base de données PostgreSQL pour n8n, directement sur pgAdmin, avec un utilisateur n8n (n’oubliez pas de mettre le privilège Can login?) et de l’associer en Owner sur une même base de données n8n.
Nous voulons aussi lui dédié un volume Longhorn pour la persistance des données. Créer un volume n8n-data de 8Gi via l’interface Longhorn.

Ensuite, créer un PersistentVolume (et uniquement un PV, pas de PVC) pour ce volume Longhorn. Laissez les paramètres par défaut. Il devrait prendre le StorageClass longhorn-crypto par défaut (précisé lors de l’installation de longhorn).

Enfin, n’oubliez pas de rajouter le groupe backup à ce volume pour qu’il soit inclus dans les backups réguliers.

Tip
L’intérêt principal de passer par l’interface Longhorn pour créer le volume est de faciliter la restauration en s’assurant que le nom du volume et du backup restent consistants.
Secrets n8n
Nous allons maintenant créer les secrets nécessaires pour n8n (clé encryption n8n, mdp postgres, accès smtp), en utilisant SOPS pour chiffrer les informations sensibles. Créer les fichiers suivants dans clusters/dev/tools/ :
apiVersion: v1kind: Secretmetadata: name: encryption-key-secret namespace: n8ntype: Opaquedata: N8N_ENCRYPTION_KEY: <openssl rand -hex 16 | base64>apiVersion: v1kind: Secretmetadata: name: database-secret namespace: n8ntype: Opaquedata: postgres-password: <votre-mot-de-passe-postgres | base64>apiVersion: v1kind: Secretmetadata: name: smtp-secret namespace: n8ntype: Opaquedata: N8N_SMTP_USER: <guid | base64> N8N_SMTP_PASS: <guid | base64>Pour faciliter le chiffrement des fichiers secrets avec SOPS, créer un fichier .sops.yaml à la racine du dépôt Git :
creation_rules: - path_regex: clusters/dev/.*/secret-.*.yaml encrypted_regex: ^(data|stringData)$ age: <votre clé publique age créée lors de l'installation de flux>Plus qu’à chiffrer les fichiers secrets avec la commande sops.
sops -e -i clusters/dev/tools/secret-n8n-database.yamlsops -e -i clusters/dev/tools/secret-n8n-encryption-key.yamlsops -e -i clusters/dev/tools/secret-n8n-smtp.yamlSur vscode, l’extension https://github.com/kabolt/vscode-sops marche très bien.
Créer le namespace n8n puis ajouter vos secrets dans le fichier kustomization.yaml pour qu’ils soient appliqués par flux.
apiVersion: v1kind: Namespacemetadata: name: n8napiVersion: kustomize.config.k8s.io/v1beta1kind: Kustomizationresources: - deployment-n8n.yaml - secret-n8n-database.yaml - secret-n8n-smtp.yaml - secret-n8n-encryption-key.yamlPuis commiter le tout et lancer une synchronisation avec la commande flux reconcile kustomization flux-system --with-source.
Vous devriez voir vos secrets déchiffrés via kgsec -n n8n.
Déploiement n8n
Plus qu’à déployer n8n via le fichier deploy-n8n.yaml.
# ...---apiVersion: source.toolkit.fluxcd.io/v1kind: HelmRepositorymetadata: name: n8n namespace: flux-systemspec: interval: 1h0m0s url: https://community-charts.github.io/helm-charts---apiVersion: helm.toolkit.fluxcd.io/v2kind: HelmReleasemetadata: name: n8n namespace: flux-systemspec: chart: spec: chart: n8n reconcileStrategy: ChartVersion sourceRef: kind: HelmRepository name: n8n version: ">=1.15.16" interval: 1m releaseName: n8n targetNamespace: n8n maxHistory: 2
values: strategy: type: Recreate
main: persistence: enabled: true existingClaim: n8n-data volumeName: n8n-data extraEnvVars: N8N_EMAIL_MODE: smtp N8N_SMTP_HOST: smtp.tem.scw.cloud N8N_SMTP_PORT: "465" N8N_SMTP_SENDER: n8n@ohmytalos.io extraSecretNamesForEnvFrom: - smtp-secret
existingEncryptionKeySecret: encryption-key-secret
db: type: postgresdb
externalPostgresql: host: ohmytalos-dev-rw.postgres username: n8n database: n8n existingSecret: database-secret
serviceMonitor: enabled: true
webhook: url: https://n8n.ohmytalos.io/---apiVersion: v1kind: PersistentVolumeClaimmetadata: name: n8n-data namespace: n8nspec: resources: requests: storage: 8Gi volumeName: n8n-data storageClassName: longhorn-crypto accessModes: - ReadWriteOnce---apiVersion: traefik.io/v1alpha1kind: IngressRoutemetadata: name: n8n namespace: n8nspec: routes: - match: Host(`n8n.ohmytalos.io`) kind: Rule services: - name: n8n port: httpExplanation
On crée d’abord un HelmRepository pour référencer le dépôt Helm officiel de n8n, puis un HelmRelease pour déployer n8n dans le namespace n8n, en utilisant les secrets précédemment créés pour la configuration.
La configuration de la base de données PostgreSQL externe est spécifiée via externalPostgresql, en utilisant le secret database-secret pour les informations d’authentification.
Enfin, on crée un PersistentVolumeClaim pour le volume Longhorn n8n-data, et une IngressRoute Traefik pour exposer n8n publiquement via le domaine n8n.ohmytalos.io. Si uniquement à vocation interne, utiliser n8n.dev.ohmytalos.io.
Et voilà, une fois le commit poussé et la synchronisation effectuée, n8n devrait être opérationnel dans votre cluster Kubernetes.

Outils DevOps
Déploiement Gitea
Allons plus loin avec l’installation de notre propre solution self-hosted VCS + CI légère, Gitea. Le processus est en tout point similaire à celui de n8n.
- Création de la base de données PostgreSQL
giteaavec un utilisateurgiteasur pgAdmin. - Création du volume Longhorn
gitea-datachiffré de 10Gi + configuration backup, mais avec le tagvolumepour stocker les données dans les disques attachés aux nœudsstorage. - Création du
PersistentVolumegitea-datachiffré dans Kubernetes. - Création des secrets SOPS pour Gitea (mot de passe postgres, clé secrète Gitea).
- Déploiement de Gitea via HelmRelease Flux.
apiVersion: v1kind: Secretmetadata: name: gitea-admin-secret namespace: giteatype: Opaquedata: password: <password-admin-gitea | base64> username: <admin-gitea | base64>apiVersion: v1kind: Secretmetadata: name: gitea-app-ini-secret namespace: giteatype: Opaquedata: cache: HOST=redis://:<redis-password>@dragonfly.dragonfly:6379/0 database: PASSWD=<postgres-password> mailer: PASSWD=<smtp-password>USER=<smtp-username> queue: CONN_STR=redis://:<redis-password>@dragonfly.dragonfly:6379/0 session: PROVIDER_CONFIG=redis://:<redis-password>@dragonfly.dragonfly:6379/0 storage: MINIO_ACCESS_KEY_ID=<access-key>MINIO_SECRET_ACCESS_KEY=<secret-access-key>Important
La construction de la config Gitea app.ini est un peu particulière, référez-vous à la documentation officielle pour plus de détails sur les différentes options de configuration. Le secret gitea-app-ini-secret ne concerne que les valeurs sensibles nécessaires pour configurer Gitea, telles que les informations de connexion à la base de données, les paramètres SMTP pour l’envoi d’e-mails, et la configuration du cache et de la session avec Redis, le stockage S3 externe.
N’oubliez pas de remplacer les valeurs entre chevrons par les valeurs réelles de vos secrets puis réencoder le tout en base64.
Nous allons rajouter également en frontal de Gitea Anubis pour s’éviter les ennuis liés au scraping IA sur ce genre d’instance.
apiVersion: v1kind: Secretmetadata: name: gitea-anubis-key namespace: giteatype: Opaquedata: ED25519_PRIVATE_KEY_HEX: <openssl rand -hex 32 | base64>On chiffre tout ça :
sops -e -i clusters/dev/build/secret-gitea-admin.yamlsops -e -i clusters/dev/build/secret-gitea-app-ini.yamlsops -e -i clusters/dev/build/secret-gitea-anubis-key.yamlLe point d’entrée pour flux :
apiVersion: kustomize.config.k8s.io/v1beta1kind: Kustomizationresources: - deployment-gitea.yaml - secret-gitea-admin.yaml - secret-gitea-app-ini.yaml - secret-gitea-anubis-key.yamlLe déploiement Gitea complet :
apiVersion: v1kind: Namespacemetadata: name: gitea---apiVersion: source.toolkit.fluxcd.io/v1kind: HelmRepositorymetadata: name: gitea namespace: flux-systemspec: interval: 1h0m0s url: https://dl.gitea.io/charts---apiVersion: helm.toolkit.fluxcd.io/v2kind: HelmReleasemetadata: name: gitea namespace: flux-systemspec: chart: spec: chart: gitea reconcileStrategy: ChartVersion sourceRef: kind: HelmRepository name: gitea version: ">=12.4.0" interval: 1m releaseName: gitea targetNamespace: gitea maxHistory: 2
values: image: tag: "1.25.3" gitea: admin: existingSecret: gitea-admin-secret email: admin@ohmytalos.io metrics: enabled: true serviceMonitor: enabled: true additionalConfigSources: - secret: secretName: gitea-app-ini-secret config: server: DOMAIN: gitea.ohmytalos.io SSH_DOMAIN: ssh.ohmytalos.io ROOT_URL: https://gitea.ohmytalos.io database: DB_TYPE: postgres HOST: ohmytalos-dev-rw.postgres NAME: gitea USER: gitea storage: STORAGE_TYPE: local MINIO_ENDPOINT: s3.gra.io.cloud.ovh.net MINIO_LOCATION: gra MINIO_BUCKET: ohmytalos-dev MINIO_USE_SSL: true storage.attachments: STORAGE_TYPE: local MINIO_BASE_PATH: attachments/ storage.actions_log: STORAGE_TYPE: local MINIO_BASE_PATH: actions_log/ storage.packages: STORAGE_TYPE: minio MINIO_BASE_PATH: gitea/packages/ indexer: REPO_INDEXER_ENABLED: true mailer: ENABLED: true FROM: gitea@ohmytalos.io SMTP_ADDR: smtp.tem.scw.cloud SMTP_PORT: "587" cache: ADAPTER: redis session: PROVIDER: redis queue: TYPE: redis service: DISABLE_REGISTRATION: true repository: DEFAULT_BRANCH: main metrics: ENABLED_ISSUE_BY_REPOSITORY: true ENABLED_ISSUE_BY_LABEL: true webhook: ALLOWED_HOST_LIST: "*"
postgresql-ha: enabled: false
valkey-cluster: enabled: false
persistence: create: false claimName: gitea-data
strategy: type: Recreate
resources: requests: cpu: 100m memory: 1Gi limits: cpu: 2000m memory: 1Gi
tolerations: - key: node-role.kubernetes.io/storage operator: Exists nodeSelector: node.kubernetes.io/role: storage---apiVersion: v1kind: PersistentVolumeClaimmetadata: name: gitea-data namespace: giteaspec: resources: requests: storage: 10Gi volumeName: gitea-data storageClassName: longhorn-crypto accessModes: - ReadWriteOnce---apiVersion: apps/v1kind: Deploymentmetadata: name: anubis namespace: giteaspec: selector: matchLabels: app: anubis template: metadata: labels: app: anubis spec: containers: - name: anubis image: ghcr.io/techarohq/anubis:latest env: - name: BIND value: :8080 - name: DIFFICULTY value: "4" - name: ED25519_PRIVATE_KEY_HEX valueFrom: secretKeyRef: name: gitea-anubis-key key: ED25519_PRIVATE_KEY_HEX - name: METRICS_BIND value: :9090 - name: SERVE_ROBOTS_TXT value: "true" - name: TARGET value: http://gitea-http.gitea.svc:3000 - name: OG_PASSTHROUGH value: "true" - name: OG_EXPIRY_TIME value: 24h resources: limits: cpu: 750m memory: 256Mi requests: cpu: 250m memory: 256Mi securityContext: runAsUser: 1000 runAsGroup: 1000 runAsNonRoot: true allowPrivilegeEscalation: false capabilities: drop: - ALL seccompProfile: type: RuntimeDefault---apiVersion: v1kind: Servicemetadata: name: anubis namespace: giteaspec: selector: app: anubis ports: - name: http port: 8080---apiVersion: traefik.io/v1alpha1kind: IngressRouteTCPmetadata: name: gitea-ssh namespace: giteaspec: entryPoints: - ssh routes: - match: HostSNI(`*`) services: - name: gitea-ssh port: ssh---apiVersion: traefik.io/v1alpha1kind: IngressRoutemetadata: name: gitea-http namespace: giteaspec: routes: - match: Host(`gitea.ohmytalos.io`) && PathRegexp(`^/(api|v2)`) kind: Rule services: - name: gitea-http port: http - match: Host(`gitea.ohmytalos.io`) && !PathRegexp(`^/(api|v2)`) kind: Rule services: - name: anubis port: httpExplanation
Ça commence à devenir assez velu, mais le principe reste le même que pour n8n. On crée un namespace gitea, puis on déploie Gitea via un HelmRelease, en utilisant les secrets SOPS.
On s’assure de configurer l’accès au postgresql, au SMTP et au S3 OVH externe (n’oublier de configurer des accès gitea dédiés). Le stockage S3 n’est activé que sur la partie packages, le plus volumineux (artifacts, registry docker), le reste notamment les repos git restant sur le volume local longhorn.
Nous déployons Gitea sur les nœuds storage, nécessaire pour se brancher au volume Longhorn créé précédemment sur les disques externes via le tag volume.
Côté Ingress, on rajoute l’entrée ssh pour le git over ssh, et on place Anubis en frontal HTTP sauf pour les routes API et docker registry pour éviter de se faire bloquer côté client docker cli ou runner.
Commiter et espérer. Déboguer avec kgp -n gitea en cas de problème et vérifier les logs des pods Gitea et Anubis.
Et voilà, c’est magnifique, tester immédiatement la connexion admin puis la section dashboard.


Déploiement SonarQube
Allons encore plus loin avec l’installation de SonarQube pour l’analyse de la qualité du code. Le processus reste similaire à celui de n8n et Gitea, en plus simple sans nécessité de persistance.
apiVersion: v1kind: Secretmetadata: name: sonarqube-secret namespace: sonarqubetype: Opaquedata: db-password: <mot-de-passe-postgres | base64> monitoring-passcode: <mot-de-passe-monitoring | base64>apiVersion: kustomize.config.k8s.io/v1beta1kind: Kustomizationresources: - #... - deployment-sonarqube.yaml - secret-sonarqube.yamlapiVersion: v1kind: Namespacemetadata: name: sonarqube labels: pod-security.kubernetes.io/enforce: "privileged"---apiVersion: source.toolkit.fluxcd.io/v1kind: HelmRepositorymetadata: name: sonarqube namespace: flux-systemspec: interval: 1h0m0s url: https://SonarSource.github.io/helm-chart-sonarqube---apiVersion: helm.toolkit.fluxcd.io/v2kind: HelmReleasemetadata: name: sonarqube namespace: flux-systemspec: chart: spec: chart: sonarqube reconcileStrategy: ChartVersion sourceRef: kind: HelmRepository name: sonarqube version: ">=2025.6.0" interval: 1m releaseName: sonarqube targetNamespace: sonarqube maxHistory: 2
values: community: enabled: true buildNumber: "26.1.0.118079"
monitoringPasscode: null monitoringPasscodeSecretName: sonarqube-secret monitoringPasscodeSecretKey: monitoring-passcode
resources: requests: cpu: 100m memory: 3Gi limits: cpu: 1000m memory: 3Gi
prometheusMonitoring: podMonitor: enabled: true namespace: sonarqube
jdbcOverwrite: enable: true jdbcUrl: jdbc:postgresql://ohmytalos-dev-rw.postgres/sonarqube jdbcUsername: sonarqube jdbcSecretName: sonarqube-secret jdbcSecretPasswordKey: db-password
postgresql: enabled: false---apiVersion: traefik.io/v1alpha1kind: IngressRoutemetadata: name: sonarqube namespace: sonarqubespec: routes: - match: Host(`sonarqube.ohmytalos.io`) kind: Rule services: - name: sonarqube-sonarqube port: httpPour le coup bien plus simple, rien de nouveau par rapport à ce qu’on a déjà vu. Commité et admirer (attention les yeux le dark mode est uniquement présent sur la version payante, les filous).

Conclusion
Voilà, nous avons mis en place un système de déploiement continu (CD) avec Flux dans notre cluster Kubernetes. Nous avons également déployé plusieurs applications pratiques telles que n8n, Gitea et SonarQube, avec support des mises à jour automatique des charts Helm.
Il est temps de tester notre infrastructure sur toute la chaîne, sur un cas concret d’application type, du développement jusqu’à la production, suite dans la prochaine section.