<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Anatole HAGBE</title>
    <description>The latest articles on DEV Community by Anatole HAGBE (@hanatole).</description>
    <link>https://dev.to/hanatole</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3844689%2F340605b9-b40e-4e9f-9c70-6a844de7b3b1.jpeg</url>
      <title>DEV Community: Anatole HAGBE</title>
      <link>https://dev.to/hanatole</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/hanatole"/>
    <language>en</language>
    <item>
      <title>Automatiser le déploiement de BMI et observer son fonctionnement : premiers pas avec le monitoring et les logs</title>
      <dc:creator>Anatole HAGBE</dc:creator>
      <pubDate>Fri, 30 Oct 2026 11:17:20 +0000</pubDate>
      <link>https://dev.to/hanatole/automatiser-le-deploiement-de-bmi-et-observer-son-fonctionnement-premiers-pas-avec-le-monitoring-1m0l</link>
      <guid>https://dev.to/hanatole/automatiser-le-deploiement-de-bmi-et-observer-son-fonctionnement-premiers-pas-avec-le-monitoring-1m0l</guid>
      <description>&lt;p&gt;Dans les articles précédents, j'ai installé un cluster K3s avec Ansible et y ai déployé BMI, une application de calcul de l'indice de masse corporelle (IMC), depuis mon pipeline GitLab. Une commande &lt;code&gt;kubectl rollout status&lt;/code&gt; qui réussit me confirme que le déploiement a abouti et que les pods sont prêts. Mais elle ne me dit pas comment l'application se comporte ni pourquoi elle pourrait cesser de fonctionner. Une question s'impose alors : comment passer d'une application qui fonctionne à un service dont on peut observer le comportement et diagnostiquer les problèmes ?&lt;/p&gt;

&lt;h2&gt;
  
  
  Côté déploiement : peu de changements
&lt;/h2&gt;

&lt;p&gt;Pour réduire les longues attentes, qui pouvaient atteindre plusieurs dizaines de minutes sur les runners gratuits, j'ai déployé mon propre &lt;strong&gt;runner GitLab&lt;/strong&gt; dans mon cluster Kubernetes à l'aide du Helm chart officiel proposé par &lt;a href="https://docs.gitlab.com/runner/install/kubernetes/" rel="noopener noreferrer"&gt;GitLab&lt;/a&gt;. Dans le projet BMI, j'ai créé un &lt;em&gt;project runner&lt;/em&gt; et récupéré son token d'authentification. Voici un extrait de sa configuration :&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;runnerToken&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;GITLAB_TOKEN&amp;gt;"&lt;/span&gt;
&lt;span class="na"&gt;concurrent&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
&lt;span class="na"&gt;logFormat&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;json&lt;/span&gt;
&lt;span class="na"&gt;gitlabUrl&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://gitlab.com"&lt;/span&gt;
&lt;span class="na"&gt;rbac&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;create&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;apiGroups&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;configmaps&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;events&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;pods&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;pods/attach&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;pods/exec&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;secrets&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;services&lt;/span&gt;
      &lt;span class="na"&gt;verbs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;get"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;list"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;watch"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;create"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;patch"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;update"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;delete"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;span class="na"&gt;serviceAccount&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;create&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gitlab-sa&lt;/span&gt;
&lt;span class="na"&gt;runners&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;privileged&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;config&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;[[runners]]&lt;/span&gt;
      &lt;span class="s"&gt;[runners.kubernetes]&lt;/span&gt;
        &lt;span class="s"&gt;namespace = "{{.Release.Namespace}}"&lt;/span&gt;
        &lt;span class="s"&gt;image = "alpine"&lt;/span&gt;
  &lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bmi,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;kubernetes"&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;BMI-Runner"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;J'ai ensuite exécuté la commande suivante pour installer le runner dans le namespace &lt;code&gt;gitlab-runner&lt;/code&gt; :&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;helm &lt;span class="nb"&gt;install &lt;/span&gt;gitlab-runner &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-f&lt;/span&gt; values.yml &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-n&lt;/span&gt; gitlab-runner &lt;span class="nt"&gt;--create-namespace&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  oci://registry.gitlab.com/charts/charts.gitlab.io/release/gitlab-runner
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Quelques secondes plus tard, le runner était opérationnel. J'ai enfin ajouté le tag &lt;code&gt;bmi&lt;/code&gt; à mes jobs pour qu'ils puissent être exécutés sur ce runner.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi2wdwmn6gly2uflveb2q.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi2wdwmn6gly2uflveb2q.png" alt=" " width="795" height="402"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Pourquoi cette pile d'outils ?
&lt;/h2&gt;

&lt;p&gt;J'ai installé le Helm chart &lt;a href="https://github.com/prometheus-community/helm-charts/tree/main/charts/kube-prometheus-stack" rel="noopener noreferrer"&gt;kube-prometheus-stack&lt;/a&gt;, qui regroupe &lt;strong&gt;Prometheus&lt;/strong&gt;, &lt;strong&gt;Alertmanager&lt;/strong&gt;, &lt;strong&gt;Grafana&lt;/strong&gt;, &lt;strong&gt;Prometheus Operator&lt;/strong&gt;, &lt;strong&gt;Node Exporter&lt;/strong&gt; et &lt;strong&gt;kube-state-metrics&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Cette pile fournit de nombreux dashboards prêts à l'emploi ainsi que des règles d'alerte par défaut. Pour une première approche de l'observabilité, c'est un ensemble complet : je peux visualiser l'état de mon cluster en quelques minutes, sans avoir à assembler chaque composant avant d'obtenir mes premiers graphiques.&lt;/p&gt;

&lt;p&gt;Pour collecter et stocker les logs, j'ai installé respectivement les charts &lt;a href="https://grafana-community.github.io/helm-charts" rel="noopener noreferrer"&gt;Grafana Alloy&lt;/a&gt; et &lt;a href="https://grafana-community.github.io/helm-charts" rel="noopener noreferrer"&gt;Grafana Loki&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Loki indexe principalement les labels associés aux logs, plutôt que leur contenu intégral. Cette approche permet de limiter les besoins en ressources de stockage et d'indexation, à condition de maîtriser la cardinalité des labels.&lt;/p&gt;

&lt;p&gt;Alloy est l'agent de collecte et de traitement de Grafana. Il peut récupérer les logs des conteneurs, les traiter et les transmettre à Loki. Il prend également en charge la collecte de métriques et de traces. Il remplace Promtail, désormais en maintenance seulement.&lt;/p&gt;

&lt;p&gt;J'ai publié dans &lt;a href="https://gitlab.com/hanatole/bmi-cluster/-/tree/749f154c4afa992dccac525c07f7d69af5df722d/" rel="noopener noreferrer"&gt;mon dépôt GitLab&lt;/a&gt; les configurations utilisées ainsi que les procédures d'installation de ces différents charts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Premiers pas avec le monitoring
&lt;/h2&gt;

&lt;p&gt;Je m'intéresse à deux niveaux complémentaires : les ressources du cluster et le comportement de l'application.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Les ressources, avec la méthode USE.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;USE signifie &lt;em&gt;Utilization, Saturation, Errors&lt;/em&gt;. Cette méthode consiste à examiner l'utilisation, la saturation et les erreurs des ressources.&lt;/p&gt;

&lt;p&gt;Les dashboards fournis par kube-prometheus-stack me permettent notamment de suivre l'utilisation du CPU et de la mémoire, les redémarrages des conteneurs et de nombreuses autres métriques du cluster.&lt;/p&gt;

&lt;p&gt;Certaines métriques concernant mon application sont également disponibles, notamment celles liées aux ressources consommées par ses conteneurs. Je peux ainsi comparer la consommation réelle de BMI à mes estimations initiales.&lt;/p&gt;

&lt;p&gt;Un premier constat, visible sur le graphique consacré au quota CPU, est que la consommation de l'application reste inférieure à mes prévisions.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffhq58v0wstdbwklvztto.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffhq58v0wstdbwklvztto.png" alt="Resources Consumption Dashboard" width="800" height="402"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Le service, avec la méthode RED.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;RED signifie &lt;em&gt;Rate, Errors, Duration&lt;/em&gt;. Contrairement à USE, cette méthode s'intéresse au comportement du service : combien de requêtes arrivent, combien échouent et combien de temps elles prennent.&lt;/p&gt;

&lt;p&gt;J'ai instrumenté BMI pour exposer ses métriques sur l'endpoint &lt;code&gt;/metrics&lt;/code&gt; :&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;le nombre de requêtes reçues ;&lt;/li&gt;
&lt;li&gt;leur durée de traitement ;&lt;/li&gt;
&lt;li&gt;la répartition des IMC calculés par catégorie, une métrique métier complémentaire qui ne relève pas directement de RED.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzb29i10y2yregbh9auec.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzb29i10y2yregbh9auec.png" alt="Request rate" width="722" height="433"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;J'ai regroupé ces métriques dans un dashboard Grafana intitulé &lt;strong&gt;BMI Application Monitoring&lt;/strong&gt;, accessible &lt;a href="https://monitoring.bitnoises.com/dashboard/snapshot/Bbu1Nh6TbLn93bTceffXJZ2fhnaUHJby?from=now-15m&amp;amp;to=now&amp;amp;timezone=browser&amp;amp;var-DS_PROMETHEUS=prometheus&amp;amp;refresh=15s" rel="noopener noreferrer"&gt;ici&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;J'ai également configuré &lt;a href="https://prometheus.io/docs/alerting/latest/alertmanager/" rel="noopener noreferrer"&gt;Alertmanager&lt;/a&gt; pour m'envoyer des notifications sur &lt;a href="https://discord.com/" rel="noopener noreferrer"&gt;Discord&lt;/a&gt; dans trois situations :&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;le scraping des métriques de BMI échoue pendant cinq minutes consécutives ;&lt;/li&gt;
&lt;li&gt;le taux d'erreurs HTTP 5xx dépasse 0,25 requête par seconde pendant cinq minutes ;&lt;/li&gt;
&lt;li&gt;un pod redémarre plusieurs fois, quel que soit son namespace.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;La capture ci-dessous montre une notification reçue sur Discord après que j'ai modifié le Deployment de BMI pour remplacer le nom de l'image Docker par un nom d'image et provoquer une &lt;code&gt;imagePullBackOff&lt;/code&gt;. Mon application est down ce qui rend impossible la collection des métriques.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fy2zhqtbjps3ym6cnbdfd.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fy2zhqtbjps3ym6cnbdfd.png" alt="Notification Discord" width="773" height="670"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Cette expérience montre l'intérêt des alertes : elles permettent de détecter un problème sans avoir à surveiller constamment les dashboards.&lt;/p&gt;

&lt;h2&gt;
  
  
  Centraliser les logs
&lt;/h2&gt;

&lt;p&gt;BMI écrit ses logs au format JSON à l'aide de &lt;code&gt;slog&lt;/code&gt;. Alloy les collecte, puis Loki les stocke. Je peux ensuite les consulter dans Grafana.&lt;/p&gt;

&lt;p&gt;La différence avec &lt;code&gt;kubectl logs&lt;/code&gt; est concrète : je dispose d'une interface&lt;br&gt;
centralisée pour rechercher et analyser les logs, en les mettant en regard des&lt;br&gt;
autres données d'observabilité telles les métriques.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F412rj7o1ehaqnduarz1t.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F412rj7o1ehaqnduarz1t.png" alt="Logs de BMI dans Grafana" width="800" height="370"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Difficultés et enseignements
&lt;/h2&gt;

&lt;p&gt;Je n'ai pas rencontré de blocage majeur, mais la configuration de &lt;strong&gt;kube-prometheus-stack&lt;/strong&gt; m'a demandé beaucoup de lecture pour comprendre le rôle des opérateurs dans Kubernetes. J'ai également consulté les documentations de Prometheus, d'Alertmanager et d'Alloy pour mieux comprendre leur fonctionnement et choisir les paramètres adaptés à mon environnement. Cette étape m'a surtout permis de comprendre comment les différents composants s'articulent : Prometheus collecte les métriques, Grafana les visualise, Alertmanager gère les alertes, Alloy collecte les logs et Loki les stocke.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ce qu'il reste à améliorer
&lt;/h2&gt;

&lt;p&gt;Aujourd'hui, BMI est déployée automatiquement dans l'environnement de staging. Ses métriques et ses logs sont consultables dans Grafana, et des alertes m'avertissent sur Discord en cas de problème. Il me reste à ajuster les demandes et limites de ressources, à configurer correctement les probes de disponibilité et de démarrage, à revoir les ServiceAccounts et leurs permissions, puis à préparer une version adaptée à un environnement de production.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Les Helm charts kube-prometheus-stack, Loki et Alloy m'ont permis de mettre rapidement en place une stack d'observabilité pour mon cluster Kubernetes. Je peux désormais suivre le comportement de BMI, détecter les anomalies et disposer des informations nécessaires pour en identifier les causes.&lt;/p&gt;

&lt;p&gt;Pour aller plus loin, j'aimerais également installer et configurer chaque composant individuellement : Prometheus, Grafana, Alertmanager, Node Exporter, Prometheus Operator et kube-state-metrics. L'objectif n'est pas de réinventer l'existant ni de remplacer les Helm charts, mais de mieux comprendre le rôle de chaque brique, son fonctionnement et ses interactions avec les autres composants.&lt;/p&gt;

&lt;p&gt;Une prochaine étape consistera à approfondir l'instrumentation de BMI et l'exposition de ses propres métriques. J'y consacrerai un article dédié.&lt;/p&gt;

&lt;p&gt;Et vous, quelles métriques exposez-vous en priorité lorsque vous instrumentez une nouvelle application ?&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>observability</category>
      <category>prometheus</category>
      <category>grafana</category>
    </item>
    <item>
      <title>Installer mon premier cluster K3s pour BMI : choix, automatisation et premières difficultés</title>
      <dc:creator>Anatole HAGBE</dc:creator>
      <pubDate>Sun, 11 Oct 2026 21:18:35 +0000</pubDate>
      <link>https://dev.to/hanatole/installer-mon-premier-cluster-k3s-pour-bmi-choix-automatisation-et-premieres-difficultes-1ibk</link>
      <guid>https://dev.to/hanatole/installer-mon-premier-cluster-k3s-pour-bmi-choix-automatisation-et-premieres-difficultes-1ibk</guid>
      <description>&lt;p&gt;Dans le &lt;a href="https://dev.to/hanatole/de-mon-application-go-a-un-service-exploite-sur-kubernetes-point-de-depart-et-objectifs-de-mon-2g51"&gt;premier article&lt;/a&gt;, j’ai présenté BMI et le plan de mon mentorat avec &lt;a href="https://www.linkedin.com/in/edouardhinvi/" rel="noopener noreferrer"&gt;Edouard HINVI&lt;/a&gt; qui a généreusement mis à ma disposition un VPS qui tourne sous Ubuntu 26.04 LTS, avec environ 8 Go de RAM et 190 Go de disque.&lt;/p&gt;

&lt;p&gt;Dans cet article, je fais le point de comment j’ai installé un cluster &lt;strong&gt;K3s&lt;/strong&gt; avec &lt;strong&gt;Ansible&lt;/strong&gt;, et y déployé BMI à l'aide de mon pipeline GitLab.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pourquoi K3s
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://docs.k3s.io/" rel="noopener noreferrer"&gt;K3s&lt;/a&gt; est une distribution Kubernetes certifiée, légère, livrée en un seul binaire avec les composants de base déjà inclus. Je l’ai choisi, avec ses réglages par défaut, parce que mon workload est simple et léger, et que je voulais un cluster que mon VPS puisse porter sans difficulté. K3s simplifie l’installation. Plus tard, j’aimerais monter chaque composant (réseau, stockage, control plane, certificats, etc.) pour mieux comprendre le fonctionnement d’un cluster.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pourquoi Ansible plutôt que Terraform
&lt;/h2&gt;

&lt;p&gt;Le backlog prévoyait de créer le cluster avec &lt;strong&gt;Terraform&lt;/strong&gt; et &lt;strong&gt;Cloud-init&lt;/strong&gt;. Cependant, j'ai retenu &lt;strong&gt;Ansible&lt;/strong&gt; pour l'installation. En effet le VPS existait déjà : il n’y avait rien à provisionner, seulement K3s à installer. De plus le projet &lt;a href="https://docs.k3s.io/" rel="noopener noreferrer"&gt;K3s&lt;/a&gt; maintient une collection, &lt;a href="https://github.com/k3s-io/k3s-ansible" rel="noopener noreferrer"&gt;k3s-ansible&lt;/a&gt;, qui automatise la création du cluster. Je n’ai qu’à décrire mon serveur et à lancer le playbook. Si j’avais dû créer le serveur, &lt;strong&gt;Terraform&lt;/strong&gt; avec &lt;strong&gt;cloud-init&lt;/strong&gt; aurait été le bon choix. Ce choix m'a poussé à modifier légèrement mon schéma d'architecture.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbdrfc5dh6uxeqqy4ojg5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbdrfc5dh6uxeqqy4ojg5.png" alt="Nouvelle architecture" width="799" height="498"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Installation
&lt;/h2&gt;

&lt;p&gt;J'ai créé un nouveau &lt;a href="https://gitlab.com/hanatole/bmi-cluster" rel="noopener noreferrer"&gt;projet Ansible&lt;/a&gt; en dehors de &lt;strong&gt;BMI&lt;/strong&gt; pour installer le cluster et conserver les templates des manifests de déploiement. Il est géré avec &lt;a href="https://docs.astral.sh/uv/" rel="noopener noreferrer"&gt;uv&lt;/a&gt;, un gestionnaire de paquets et d’environnements Python rapide. Il isole les dépendances, facilite la gestion des versions et évite les conflits avec le système.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;project&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;span class="s"&gt;name = "bmi-cluster"&lt;/span&gt;
&lt;span class="s"&gt;version = "0.1.0"&lt;/span&gt;
&lt;span class="s"&gt;description = "Create BMI cluster and manage deployments"&lt;/span&gt;
&lt;span class="s"&gt;requires-python = "&amp;gt;=3.14"&lt;/span&gt;
&lt;span class="s"&gt;dependencies = [&lt;/span&gt;
    &lt;span class="s"&gt;"ansible&amp;gt;=14.5.0",&lt;/span&gt;
    &lt;span class="s"&gt;"ansible-lint&amp;gt;=26.9.0",&lt;/span&gt;
    &lt;span class="s"&gt;"netaddr&amp;gt;=1.3.0",&lt;/span&gt;
&lt;span class="err"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ensuite, j'ai créé un fichier &lt;a href="https://gitlab.com/hanatole/bmi-cluster/-/blob/399bbb36068b1bcfb868f76edd2848f11d0681e9/requirements.yml" rel="noopener noreferrer"&gt;requirements.yml&lt;/a&gt; qui référence la collection k3s-ansible.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;collections&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://github.com/k3s-io/k3s-ansible.git&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;git&lt;/span&gt;
    &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1.2.2&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Son installation est très simple :&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;uv run ansible-galaxy &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; requirements.yml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Le fichier &lt;strong&gt;inventory.yml&lt;/strong&gt; définit les paramètres de connexion SSH au VPS, qui sont lus dans les variables d’environnement.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;k3s_cluster&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;vars&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;ansible_host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;lookup('env',&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;'CLUSTER_HOST')&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;
    &lt;span class="na"&gt;ansible_port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;lookup('env',&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;'CLUSTER_PORT')&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;
    &lt;span class="na"&gt;ansible_user&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;lookup('env',&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;'CLUSTER_USER')&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;
    &lt;span class="na"&gt;api_endpoint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;lookup('env',&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;'CLUSTER_HOST')&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;
    &lt;span class="na"&gt;k3s_version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1.37.0+k3s1&lt;/span&gt;
  &lt;span class="na"&gt;children&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;server&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;hosts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;staging&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;La création du cluster tient en une commande :&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;uv run ansible-playbook &lt;span class="nt"&gt;-i&lt;/span&gt; inventory.yml k3s.orchestration.site
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Les captures suivantes montrent le début et la fin de l'installation. Le warning sur la deuxième capture s'explique par le fait que j'ai un cluster mono-noeud : juste le server, pas d'agent.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmifmra2jq546l4dx9bmp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmifmra2jq546l4dx9bmp.png" alt="Début de l'installation" width="800" height="358"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvo8jq6rokb2m1o8lnjnq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvo8jq6rokb2m1o8lnjnq.png" alt="Fin de l'installation" width="799" height="292"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;À la fin de l'installation, j'ai vérifié le status du service systemd &lt;strong&gt;k3s&lt;/strong&gt;, du noeud et des pods.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flxkb4n55c98kxlfrl48k.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flxkb4n55c98kxlfrl48k.png" alt="Service k3s status" width="800" height="508"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw3nt8cfewecdqh683m7n.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw3nt8cfewecdqh683m7n.png" alt="Node + Pods" width="800" height="273"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Le playbook &lt;a href="https://github.com/k3s-io/k3s-ansible" rel="noopener noreferrer"&gt;k3s-ansible&lt;/a&gt; crée aussi sur le &lt;strong&gt;control node&lt;/strong&gt;, un fichier &lt;strong&gt;~/.kube/config.new&lt;/strong&gt; qui donne un accès admin au cluster.&lt;/p&gt;

&lt;h2&gt;
  
  
  Déployer BMI depuis le pipeline
&lt;/h2&gt;

&lt;p&gt;Côté &lt;a href="https://gitlab.com/hanatole/bmi" rel="noopener noreferrer"&gt;BMI&lt;/a&gt;, j’ai modifié le pipeline pour déployer sur le cluster. Il récupère le fichier &lt;a href="https://gitlab.com/hanatole/bmi-cluster/-/blob/108bc8f306bd4d6800bef8debea6e30b3dff4ad2/manifests/bmi.tpl.yml" rel="noopener noreferrer"&gt;bmi.tpl.yml&lt;/a&gt; qui contient des templates de &lt;strong&gt;namespace&lt;/strong&gt;, de &lt;strong&gt;deployment&lt;/strong&gt;, de &lt;strong&gt;service&lt;/strong&gt; et d' &lt;strong&gt;ingress&lt;/strong&gt;. Il y injecte des paramètres pour générer un manifest Kubernetes, l’applique, vérifie le déploiement et sauvegarde le manifest dans les artifacts. Voici un extrait des templates :&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Namespace&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Namespace&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${APP_NAMESPACE}&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;

&lt;span class="c1"&gt;# Service&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Service&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bmi&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${APP_NAMESPACE}&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bmi&lt;/span&gt;
  &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;80&lt;/span&gt;
      &lt;span class="na"&gt;targetPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Le manifest complet est dans le &lt;a href="https://gitlab.com/hanatole/bmi-cluster/-/blob/20072693e69c6e66aeb73f8fbf7c3f4549dcbc84/manifests/bmi.tpl.yml" rel="noopener noreferrer"&gt;dépôt&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Le déploiement se fait automatiquement dans l'environnement &lt;strong&gt;staging&lt;/strong&gt; quand je pousse sur la branche &lt;strong&gt;main&lt;/strong&gt;. Pour la &lt;strong&gt;production&lt;/strong&gt;, il faut un &lt;strong&gt;tag&lt;/strong&gt; et un &lt;strong&gt;clic manuel&lt;/strong&gt;. Voici un extrait du &lt;a href="https://gitlab.com/hanatole/bmi/-/blob/231710d0d05b0c2dcd76f7234289552bd28aaf1a/cicd/deploy.yml" rel="noopener noreferrer"&gt;pipeline de déploiement&lt;/a&gt; :&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;deploy-staging&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;extends&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.deploy&lt;/span&gt;
  &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;staging&lt;/span&gt;
  &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;$CI_COMMIT_BRANCH&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;==&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;$CI_DEFAULT_BRANCH'&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;when&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;never&lt;/span&gt;

&lt;span class="na"&gt;deploy-production&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;extends&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.deploy&lt;/span&gt;
  &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;production&lt;/span&gt;
  &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;$CI_COMMIT_TAG'&lt;/span&gt;
      &lt;span class="na"&gt;when&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;manual&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;when&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;never&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Le &lt;strong&gt;kubeconfig&lt;/strong&gt; est stocké dans une variable GitLab secrète et masquée (&lt;strong&gt;KUBECONFIG_B64&lt;/strong&gt;), encodée en base64. Il n’apparaît donc jamais dans les logs.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fy9qtxs090uk1a4b7w2th.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fy9qtxs090uk1a4b7w2th.png" alt="Extrait du pipeline" width="752" height="202"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Difficultés
&lt;/h2&gt;

&lt;p&gt;Ma principale difficulté a été de choisir les bonnes valeurs pour les ressources (&lt;strong&gt;cpu&lt;/strong&gt; et &lt;strong&gt;memory&lt;/strong&gt;) du Deployment, ainsi que les paramètres des probes (&lt;strong&gt;readinessProbe&lt;/strong&gt; et &lt;strong&gt;livenessProbe&lt;/strong&gt;). Pour l’instant, mes valeurs sont des estimations. Je vais les ajuster en observant la consommation réelle de BMI avec le monitoring, et en testant les probes sur le temps de démarrage de l’application.&lt;/p&gt;

&lt;p&gt;Une autre est liée aux limites des runners du compte gratuit sur GitLab : mon pipeline reste parfois plus de 30 minutes en attente avant de démarrer. Je compte donc déployer mon propre runner dans le cluster Kubernetes et l'utiliser les prochaines fois.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prochaines étapes
&lt;/h2&gt;

&lt;p&gt;Rien n’a cassé, mais un cluster qui répond ne fait pas un service exploité. La semaine prochaine, je travaillerai sur le monitoring du cluster et de l’application, puis sur la centralisation des logs.&lt;/p&gt;

&lt;p&gt;Je suis conscient que le &lt;strong&gt;kubeconfig&lt;/strong&gt; actuellement donne un accès admin au cluster, ce qui est trop large pour un simple déploiement. Je vais créer un ServiceAccount limité au namespace de BMI, un par environnement.  Et vous, comment sécurisez-vous l’accès de vos pipelines à votre cluster ?&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>ansible</category>
      <category>cicd</category>
      <category>french</category>
    </item>
    <item>
      <title>De mon application Go à un service exploité sur Kubernetes : Point de départ et objectifs de mon mentorat</title>
      <dc:creator>Anatole HAGBE</dc:creator>
      <pubDate>Sun, 04 Oct 2026 17:00:46 +0000</pubDate>
      <link>https://dev.to/hanatole/de-mon-application-go-a-un-service-exploite-sur-kubernetes-point-de-depart-et-objectifs-de-mon-2g51</link>
      <guid>https://dev.to/hanatole/de-mon-application-go-a-un-service-exploite-sur-kubernetes-point-de-depart-et-objectifs-de-mon-2g51</guid>
      <description>&lt;p&gt;J'ai créé BMI, une application qui calcule l'indice de masse corporelle. Le sujet concerne beaucoup de monde, mais surtout, c'est un support que je maîtrise de bout en bout pour travailler ce qui m'intéresse vraiment : l'exploitation d'un service.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5v42yt1v0y3ucn7t9asi.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5v42yt1v0y3ucn7t9asi.png" alt="BMI Form" width="548" height="663"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcwfb6e2kdnolswpvla6x.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcwfb6e2kdnolswpvla6x.png" alt="BMI Response" width="548" height="663"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Les captures d'écran montrent l'état actuel de BMI. Un formulaire demande le nom, la taille et le poids. Une fois validé, un modal affiche l'IMC, la catégorie correspondante (normal, surpoids, etc.) et un court message, par exemple une invitation à revoir son alimentation. Aucune donnée n'est stockée, il n'y a pas d'authentification et aucun secret à gérer. Le code est en Go, avec la seule bibliothèque standard pour l'instant. Plus tard, j'imagine un compte utilisateur, un historique pour suivre son évolution et des notifications.&lt;/p&gt;

&lt;p&gt;Côté DevOps, il y a déjà une base. Le code est sur &lt;a href="https://gitlab.com/hanatole/bmi" rel="noopener noreferrer"&gt;GitLab&lt;/a&gt;, et un pipeline exécute les tests unitaires, construit l'image Docker, la scanne, puis la pousse sur Docker Hub. Un &lt;a href="https://bmi-staging.bitnoises.com" rel="noopener noreferrer"&gt;environnement de test&lt;/a&gt; tourne sur AWS et l'application expose un healthcheck. En revanche, il n'y a pas encore de supervision. C'est là que se situe la différence entre une application qui fonctionne et un service exploitable.&lt;/p&gt;

&lt;p&gt;BMI n'a jamais été soumise à de vraies conditions de production. Je ne sais pas encore comment elle réagit quand un déploiement échoue, quand un pod redémarre ou quand il faut comprendre une panne à partir de ce qu'on observe plutôt que de ce qu'on suppose. Un healthcheck dit si le service répond, pas pourquoi il ne répond plus.&lt;/p&gt;

&lt;p&gt;Pour travailler ces questions, je serai accompagné pendant quatre semaines (deux séances de deux heures par semaine) par &lt;a href="https://www.linkedin.com/in/edouardhinvi/" rel="noopener noreferrer"&gt;Edouard HINVI&lt;/a&gt;, Senior Ingénieur DevOps et mentor chez &lt;a href="https://helpallskills.com/" rel="noopener noreferrer"&gt;Help All Skills&lt;/a&gt; qui organise cet accompagnement. Nous avons posé un backlog dès la première séance. Je joue à la fois le rôle de développeur et de DevOps, mais le mentorat cible uniquement le second.&lt;/p&gt;

&lt;p&gt;Cette semaine, j’ai mis à jour &lt;a href="https://gitlab.com/hanatole/bmi/-/blob/81c167ba17a6f2f43baa9c9d7714455b8dae3839/README.md" rel="noopener noreferrer"&gt;la documentation&lt;/a&gt; de BMI en y ajoutant, entre autres, le schéma de l’architecture cloud. J’ai opté pour un cluster K3s mono-nœud, compte tenu de mon budget limité. J’ai enfin mis en place le pipeline qui construit l’image, la scanne, puis la pousse dans le registre Docker Hub.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F04l2n0vd9m12vref1y4s.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F04l2n0vd9m12vref1y4s.png" alt="Cluster architecture" width="800" height="537"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;La semaine prochaine, je créerai le cluster avec Terraform, y déploierai BMI et mettrai l’application en ligne en HTTPS.&lt;/p&gt;

&lt;p&gt;Je passe mes journées à découvrir de nouveaux outils, à lire et à pratiquer, notamment en m’appuyant sur le blog de &lt;a href="https://www.linkedin.com/in/stephanerobert1/" rel="noopener noreferrer"&gt;Stéphane ROBERT&lt;/a&gt;. Mais manipuler Kubernetes ne signifie pas encore savoir réellement l’exploiter. C’est précisément cet écart que je veux combler. C’est ambitieux pour quatre semaines, et je ne sais pas encore si tout tiendra dans le délai imparti. Les inconnues seront listées dès la première étape. Je publierai un article par semaine sur ce qui avance, mais aussi sur ce qui résiste.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>kubernetes</category>
      <category>cloud</category>
      <category>go</category>
    </item>
    <item>
      <title>Running Sonatype Nexus 3 on a 1 Gi RAM VPS — A Practical Guide</title>
      <dc:creator>Anatole HAGBE</dc:creator>
      <pubDate>Sun, 05 Apr 2026 12:59:40 +0000</pubDate>
      <link>https://dev.to/hanatole/running-sonatype-nexus-3-on-a-1-gi-ram-vps-a-practical-guide-3bec</link>
      <guid>https://dev.to/hanatole/running-sonatype-nexus-3-on-a-1-gi-ram-vps-a-practical-guide-3bec</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — I self-host a private Docker registry and artifact store using Sonatype Nexus 3 on a VPS with only 1 Gi of RAM and 25 Gi of SSD. This post covers every decision made, every pitfall hit, and every config line tuned to make it work reliably in production.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Why Self-Host a Registry?
&lt;/h2&gt;

&lt;p&gt;Every time I push an image to Docker Hub on a free plan, I think about rate limits, retention policies, and the slow creep of vendor lock-in. For personal and small team projects, a self-hosted registry gives you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No rate limits&lt;/strong&gt; on pulls (critical in CI/CD pipelines)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Private images&lt;/strong&gt; without paying for a cloud registry&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A single artifact store&lt;/strong&gt; for Docker images, npm packages, Maven artifacts, and more — all under one roof&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Full control&lt;/strong&gt; over retention and access&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The catch: &lt;code&gt;Sonatype Nexus 3&lt;/code&gt; is a Java application. It was built for enterprise servers, not budget VPS instances. The official documentation recommends a minimum of 8 Gi of RAM. Running it on 1 Gi is a constraint-driven engineering problem — and those are my favourite kind.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Setup
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Infrastructure
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;| Component     | Details                                                           |
| ------------- | ------------------------------------------------------------------|
| VPS           | 1 vCPU, 1 Gi RAM, 25 Gi SSD                                       |
| OS            | Ubuntu 22.04 LTS                                                  |
| Nexus version | 3.90.2-alpine                                                     |
| Database      | PostgreSQL (external, on the same host)                           |
| Reverse proxy | Traefik v3 with automatic TLS                                     |
| DNS           | `repository.bitnoises.com` (UI), `registry.bitnoises.com` (Docker)|
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Architecture
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fbipepshxju8e41m6fdqq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fbipepshxju8e41m6fdqq.png" alt="Architecture" width="800" height="730"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Traefik handles all TLS termination. Nexus never sees raw HTTPS traffic — it only speaks plain HTTP internally, which simplifies configuration considerably.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Memory Problem
&lt;/h2&gt;

&lt;p&gt;Here is the honest picture of my RAM budget before Nexus even starts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Total RAM:              957 Mi
OS + kernel:           ~150 Mi
Docker daemon:          ~50 Mi
Traefik:                ~30 Mi
PostgreSQL:             ~80 Mi
─────────────────────────────
Available for Nexus:   ~647 Mi
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nexus is a JVM application. Its memory footprint has two main components:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Heap&lt;/strong&gt; (&lt;code&gt;-Xmx&lt;/code&gt;) — object allocations, managed by the garbage collector&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Direct memory&lt;/strong&gt; (&lt;code&gt;-XX:MaxDirectMemorySize&lt;/code&gt;) — off-heap buffers, used heavily for I/O&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The default Sonatype recommendation is &lt;code&gt;-Xmx2703m&lt;/code&gt;. On my VPS, that would require 4× the available RAM. The JVM would immediately start swapping and the container would be OOM-killed within minutes.&lt;/p&gt;

&lt;p&gt;The solution is aggressive but careful downsizing.&lt;/p&gt;

&lt;h3&gt;
  
  
  JVM Tuning
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;-Xms128m                      # Start heap small, grow as needed
-Xmx384m                      # Hard heap ceiling
-XX:MaxDirectMemorySize=192m  # Off-heap buffer ceiling
-XX:+UseG1GC                  # Better GC under memory pressure
-XX:MaxGCPauseMillis=300      # GC pause target
-XX:G1HeapRegionSize=4m       # Smaller regions = less wasted space
-XX:+UseStringDeduplication   # G1 deduplicates identical strings (~5-10% heap savings)
-XX:SoftRefLRUPolicyMSPerMB=0 # Aggressively clear soft references under pressure
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key insight is separating &lt;code&gt;-Xms&lt;/code&gt; from &lt;code&gt;-Xmx&lt;/code&gt;. Starting at 128m means the JVM consumes minimal RAM on boot and only grows the heap as Nexus actually needs it. On a quiet personal registry, it rarely needs to grow much past 250–300m in practice.&lt;/p&gt;

&lt;p&gt;Total JVM footprint: ~680m. Docker &lt;code&gt;mem_limit&lt;/code&gt; is set to 700m, leaving a 20m buffer for JVM overhead variability.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Swap Imperative
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Never run a JVM application on a machine with no swap.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Swap on SSD is not a performance strategy — it is a safety net. Without it, the Linux OOM killer will terminate your container the instant it crosses the memory limit, with no warning and no graceful shutdown. Nexus does not handle abrupt kills well; you risk database corruption or a blob store in an inconsistent state.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;fallocate &lt;span class="nt"&gt;-l&lt;/span&gt; 2G /swapfile
&lt;span class="nb"&gt;chmod &lt;/span&gt;600 /swapfile
mkswap /swapfile
swapon /swapfile
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'/swapfile none swap sw 0 0'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; /etc/fstab

&lt;span class="c"&gt;# Only use swap as a last resort&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'vm.swappiness=10'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; /etc/sysctl.conf
sysctl &lt;span class="nt"&gt;-p&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;vm.swappiness=10&lt;/code&gt;, Linux strongly prefers to keep data in RAM and only swills to the swapfile under real pressure. The SSD takes a minor hit, but your service survives spikes.&lt;/p&gt;

&lt;p&gt;After adding swap, my memory picture looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;free &lt;span class="nt"&gt;-h&lt;/span&gt;
               total   used    free    buff/cache  available
Mem:           957Mi   194Mi   91Mi    671Mi       594Mi
Swap:          2.0Gi   125Mi   1.9Gi
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;buff/cache&lt;/code&gt; column (671Mi) looks alarming but is not — Linux uses free RAM as disk cache, and that cache is immediately evictable the moment Nexus needs it. The &lt;code&gt;available&lt;/code&gt; column (594Mi) is the number that matters.&lt;/p&gt;




&lt;h2&gt;
  
  
  The compose.yml
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;nexus&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sonatype/nexus3:3.90.2-alpine&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nexus&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;user&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;200:200"&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;INSTALL4J_ADD_VM_PARAMS&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;&amp;gt;-&lt;/span&gt;
        &lt;span class="s"&gt;-Xms128m&lt;/span&gt;
        &lt;span class="s"&gt;-Xmx384m&lt;/span&gt;
        &lt;span class="s"&gt;-XX:MaxDirectMemorySize=192m&lt;/span&gt;
        &lt;span class="s"&gt;-XX:+UseG1GC&lt;/span&gt;
        &lt;span class="s"&gt;-XX:MaxGCPauseMillis=300&lt;/span&gt;
        &lt;span class="s"&gt;-XX:G1HeapRegionSize=4m&lt;/span&gt;
        &lt;span class="s"&gt;-XX:+UseStringDeduplication&lt;/span&gt;
        &lt;span class="s"&gt;-XX:SoftRefLRUPolicyMSPerMB=0&lt;/span&gt;
        &lt;span class="s"&gt;-Djava.util.prefs.userRoot=/nexus-data/javaprefs&lt;/span&gt;
        &lt;span class="s"&gt;-Dnexus.datastore.enabled=true&lt;/span&gt;
        &lt;span class="s"&gt;-Dnexus-ssl-proxy=true&lt;/span&gt;
      &lt;span class="na"&gt;NEXUS_DATASTORE_NEXUS_JDBCURL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;jdbc:postgresql://${DB_HOST}:5432/${DB_NAME}&lt;/span&gt;
      &lt;span class="na"&gt;NEXUS_DATASTORE_NEXUS_USERNAME&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${DB_USER}&lt;/span&gt;
      &lt;span class="na"&gt;NEXUS_DATASTORE_NEXUS_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${DB_PASSWORD}&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;./data:/nexus-data"&lt;/span&gt;
    &lt;span class="na"&gt;mem_limit&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;700m&lt;/span&gt;
    &lt;span class="na"&gt;memswap_limit&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1400m&lt;/span&gt;      &lt;span class="c1"&gt;# 700m RAM + 700m swap headroom&lt;/span&gt;
    &lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CMD-SHELL"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;curl&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-sf&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;http://localhost:8081/service/rest/v1/status&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;||&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;exit&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;1"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;30s&lt;/span&gt;
      &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10s&lt;/span&gt;
      &lt;span class="na"&gt;retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;
      &lt;span class="na"&gt;start_period&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;180s&lt;/span&gt;      &lt;span class="c1"&gt;# Nexus is slow to boot on constrained hardware&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;traefiknetwork&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;infra&lt;/span&gt;
    &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;traefik.enable=true"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;traefik.docker.network=traefiknetwork"&lt;/span&gt;
      &lt;span class="c1"&gt;# UI&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;traefik.http.routers.nexus-ui.rule=Host(`${NEXUS_HOST}`)"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;traefik.http.routers.nexus-ui.entrypoints=websecure"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;traefik.http.routers.nexus-ui.tls=true"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;traefik.http.routers.nexus-ui.tls.certresolver=letsencrypt"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;traefik.http.routers.nexus-ui.service=nexus-ui"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;traefik.http.services.nexus-ui.loadbalancer.server.port=8081"&lt;/span&gt;
      &lt;span class="c1"&gt;# Docker registry&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;traefik.http.routers.nexus-docker.rule=Host(`${REGISTRY_HOST}`)"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;traefik.http.routers.nexus-docker.entrypoints=websecure"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;traefik.http.routers.nexus-docker.tls=true"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;traefik.http.routers.nexus-docker.tls.certresolver=letsencrypt"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;traefik.http.routers.nexus-docker.service=nexus-docker"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;traefik.http.services.nexus-docker.loadbalancer.server.port=5000"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;traefik.http.middlewares.docker-headers.headers.customrequestheaders.Docker-Distribution-Api-Version=registry/2.0"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;traefik.http.middlewares.nexus-docker-buffering.buffering.maxRequestBodyBytes=0"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;traefik.http.routers.nexus-docker.middlewares=docker-headers,nexus-docker-buffering"&lt;/span&gt;

&lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;traefiknetwork&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;external&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;infra&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;external&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Things worth calling out
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;user: "200:200"&lt;/code&gt;&lt;/strong&gt; — The Nexus image internally runs as UID 200. Explicitly setting this prevents accidental root execution. The &lt;code&gt;./data&lt;/code&gt; directory must be pre-owned: &lt;code&gt;sudo chown -R 200:200 ./data&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;start_period: 180s&lt;/code&gt;&lt;/strong&gt; — Without this, Docker marks the container unhealthy before it has finished booting, which can trigger restart loops. On constrained hardware, Nexus takes 2–3 minutes to start.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;maxRequestBodyBytes=0&lt;/code&gt;&lt;/strong&gt; — The single most important Traefik label for a Docker registry. Without it, pushing any image layer larger than Traefik's default body size limit (2m) will fail with a cryptic 413 error.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;-Dnexus-ssl-proxy=true&lt;/code&gt;&lt;/strong&gt; — Tells Nexus it is behind a TLS-terminating proxy. Without this, the UI generates incorrect &lt;code&gt;http://&lt;/code&gt; URLs in some contexts.&lt;/p&gt;




&lt;h2&gt;
  
  
  PostgreSQL vs Embedded Database
&lt;/h2&gt;

&lt;p&gt;Nexus 3 supports two database backends: an embedded H2 database and external PostgreSQL. I chose PostgreSQL for several reasons:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Reliability&lt;/strong&gt; — H2 is fine for development, but I've seen it corrupt under abrupt JVM kills. PostgreSQL handles dirty shutdowns gracefully.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Observability&lt;/strong&gt; — I can query the database directly to inspect state, run backups, and monitor connection counts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Consistency&lt;/strong&gt; — PostgreSQL is already running on my infrastructure for other services. One less moving part.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The tradeoff: there is no &lt;code&gt;admin.password&lt;/code&gt; file on a PostgreSQL-backed install. The default credentials are simply &lt;code&gt;admin&lt;/code&gt; / &lt;code&gt;admin123&lt;/code&gt;, and Nexus forces a password change on first login.&lt;/p&gt;




&lt;h2&gt;
  
  
  Configuring the Docker Registry
&lt;/h2&gt;

&lt;p&gt;After the first login:&lt;/p&gt;

&lt;h3&gt;
  
  
  Enable the Bearer Token Realm
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;Administration&lt;/code&gt; → &lt;code&gt;Security&lt;/code&gt; → &lt;code&gt;Realms&lt;/code&gt; → move &lt;strong&gt;Docker Bearer Token Realm&lt;/strong&gt; to Active.&lt;/p&gt;

&lt;p&gt;This is the single most common cause of &lt;code&gt;401 Unauthorized&lt;/code&gt; errors when using &lt;code&gt;docker login&lt;/code&gt;. It must be enabled.&lt;/p&gt;

&lt;h3&gt;
  
  
  Create the Repository
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;Administration&lt;/code&gt; → &lt;code&gt;Repository&lt;/code&gt; → &lt;code&gt;Repositories&lt;/code&gt; → &lt;strong&gt;Create repository&lt;/strong&gt; → &lt;strong&gt;docker (hosted)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Set the HTTP port to &lt;code&gt;5000&lt;/code&gt; (this is what Traefik will route to), leave HTTPS unchecked (Traefik handles that), and set the deployment policy to your preference.&lt;/p&gt;




&lt;h2&gt;
  
  
  CI/CD Integration
&lt;/h2&gt;

&lt;p&gt;I created a dedicated &lt;code&gt;ci&lt;/code&gt; user with a minimal role, following the principle of least privilege. The role has only the privileges needed to push and pull from the Docker repository — no admin access, no access to other repositories.&lt;/p&gt;

&lt;p&gt;In GitLab CI/CD:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;stages&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;push&lt;/span&gt;

&lt;span class="na"&gt;push-alpine-to-nexus&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;stage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;push&lt;/span&gt;
  &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker:29.2.1&lt;/span&gt;
  &lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;docker:29.2.1-dind&lt;/span&gt;

  &lt;span class="na"&gt;variables&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;DOCKER_TLS_CERTDIR&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;

  &lt;span class="na"&gt;script&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# Log in to the private registry&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;echo "$NEXUS_PASSWORD" | docker login registry.bitnoises.com \&lt;/span&gt;
        &lt;span class="s"&gt;--username "$NEXUS_USER" \&lt;/span&gt;
        &lt;span class="s"&gt;--password-stdin&lt;/span&gt;

    &lt;span class="c1"&gt;# Pull Alpine image from Docker Hub&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;docker pull alpine:latest&lt;/span&gt;

    &lt;span class="c1"&gt;# Tag the image for the private registry&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;docker tag alpine:latest registry.bitnoises.com/alpine:latest&lt;/span&gt;

    &lt;span class="c1"&gt;# Push the image to Nexus&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;docker push registry.bitnoises.com/alpine:latest&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Using the commit SHA as the image tag instead of &lt;code&gt;latest&lt;/code&gt; gives you full traceability — every deployment can be traced back to an exact commit.&lt;/p&gt;




&lt;h2&gt;
  
  
  Disk Management
&lt;/h2&gt;

&lt;p&gt;The SSD is the other constraint. 25 Gi sounds like a lot until you start storing Docker images — a typical Node.js app image is 200–400 Mi, and you accumulate many versions fast.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cleanup Policies
&lt;/h3&gt;

&lt;p&gt;In &lt;code&gt;Administration&lt;/code&gt; → &lt;code&gt;Repository&lt;/code&gt; → &lt;code&gt;Cleanup Policies&lt;/code&gt;, I created a policy that removes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Components older than &lt;strong&gt;30 days&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Components not downloaded in &lt;strong&gt;14 days&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Attached to the &lt;code&gt;docker-hosted&lt;/code&gt; repository and scheduled as a weekly task, this keeps the blob store from growing unbounded.&lt;/p&gt;

&lt;h3&gt;
  
  
  Monitoring
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# How much is Nexus actually using?&lt;/span&gt;
&lt;span class="nb"&gt;du&lt;/span&gt; &lt;span class="nt"&gt;-sh&lt;/span&gt; ./data/blobs/

&lt;span class="c"&gt;# Docker layer cache on the host&lt;/span&gt;
docker system &lt;span class="nb"&gt;df&lt;/span&gt;

&lt;span class="c"&gt;# Full picture&lt;/span&gt;
&lt;span class="nb"&gt;df&lt;/span&gt; &lt;span class="nt"&gt;-h&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Lessons Learned
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;JVM memory is not just heap.&lt;/strong&gt; Many guides say "set &lt;code&gt;-Xmx&lt;/code&gt; to half your RAM" without mentioning direct memory, metaspace, code cache, or JVM thread stacks. The real footprint is heap + direct + ~100m of JVM internals. Budget for all of it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Swap before anything else.&lt;/strong&gt; I almost deployed without swap because "SSD swap is slow." SSD swap at 10% swappiness is effectively never used during normal operation, but it has saved me from OOM kills more than once during Nexus startup when memory pressure is highest.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Traefik label order matters.&lt;/strong&gt; The &lt;code&gt;middlewares&lt;/code&gt; label must reference middleware names defined in other labels in the same service. If you define &lt;code&gt;docker-headers&lt;/code&gt; and &lt;code&gt;nexus-docker-buffering&lt;/code&gt; but only reference one in the router label, the other silently does nothing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;start_period&lt;/code&gt; is not optional for slow services.&lt;/strong&gt; Docker's healthcheck &lt;code&gt;start_period&lt;/code&gt; is the grace period before failed checks count against &lt;code&gt;retries&lt;/code&gt;. For a service that takes 2–3 minutes to boot, setting this to &lt;code&gt;30s&lt;/code&gt; means Docker will restart the container before it has even finished starting — creating an infinite restart loop that looks like a memory issue.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;PostgreSQL default credentials are not in the logs.&lt;/strong&gt; Coming from H2-backed Nexus where an &lt;code&gt;admin.password&lt;/code&gt; file is generated, this caught me off guard. The PostgreSQL-backed install simply uses &lt;code&gt;admin&lt;/code&gt; / &lt;code&gt;admin123&lt;/code&gt; with no file or log entry indicating this.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Result
&lt;/h2&gt;

&lt;p&gt;A fully functional private Docker registry and artifact store, running reliably on hardware that costs a few euros a month. Memory usage in steady state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;docker stats nexus &lt;span class="nt"&gt;--no-stream&lt;/span&gt;
CONTAINER   CPU %   MEM USAGE / LIMIT   MEM %
nexus       0.3%    412MiB / 700MiB     58.9%
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;58% memory utilisation at idle, with 42% headroom before the hard limit and 700m of swap available beyond that. For a personal infrastructure project, that is a comfortable margin.&lt;/p&gt;

&lt;p&gt;The full configuration, scripts, and documentation are available on GitHub: &lt;a href="https://gitlab.com/hanatole/nexus" rel="noopener noreferrer"&gt;gitlab.com/hanatole/nexus&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Have questions or improvements? Open an issue on the repository.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>docker</category>
      <category>cicd</category>
      <category>linux</category>
    </item>
  </channel>
</rss>
