Lorsque je dispose d’un serveur, je m’arrange toujours pour y mettre Talos, l’OS minimaliste permettant de faire tourner du Kubernetes. C’est pratique, facile à installer, et c’est le summum de l’automatisation où une API permet de gérer l’OS et la configuration du cluster. Même si ma machine est seule dans son réseau (e.g. un dédié OVH), avoir un cluster mono-node sans HA me permet quand même de profiter de tous les avantages de l’API-Server : RBAC, Audit, automatisation, etc.

Ça fait des années que je fais ça et ça m’a toujours bien réussi, mais j’ai récemment été bloqué par un problème sur un nouveau provider. Celui-ci ne proposait pas de méthode d’installation suffisamment flexible pour y mettre facilement Talos.

Remarque

Petite sidenote : c’est toujours possible d’installer Talos en passant par un OS standard comme Ubuntu / Fedora et en utilisant le script de Cozystack boot-to-talos mais dans mon cas ça apportait beaucoup de contraintes : la configuration n’était pas servie par DHCP, pas d’IPMI pour debugger, etc. Bref, c’était pas la joie.

J’ai donc dû me résoudre à l’impensable : installer une vraie distribution Linux.

Meme

Donc si j’ai pas mon Talos, beh je vais faire du docker à l’ancienne, c’est à mon psy que ça va faire plaisir.

J’ai donc installé une Debian 13, j’ai mis mes docker-compose et j’ai commencé à faire tourner mes applications. C’est pas si mal, mais c’est sûr que ça manque cruellement de fun.

Et en discutant avec un collègue (coucou Aurélien) des quadlets, j’ai compris qu’il y avait plein d’avantages à faire du Podman plutôt que du Docker :

  • Daemonless : pas de daemon root qui tourne en permanence.
  • Rootless (si on veut) : les containers tournent sous ton utilisateur.
  • Gestion par systemd : redémarrages natifs, logs dans journald, et plus besoin de Watchtower.

Et pour migrer, Aurélien m’a montré un projet qui s’appelle podlet que je vais m’empresser de tester.

Installation de podlet

Podlet est disponible via brew, cargo ou en téléchargeant les binaires sur GitHub. J’ai choisi la dernière option pour éviter d’installer Rust (et parce que j’utilise pas du tout Homebrew sur Linux).

cd $(mktemp -d)
wget https://github.com/containers/podlet/releases/download/v0.3.1/podlet-x86_64-unknown-linux-gnu.tar.xz
tar -xf podlet-x86_64-unknown-linux-gnu.tar.xz
sudo mv ./podlet-x86_64-unknown-linux-gnu/podlet /usr/local/bin/

Une fois installé, on peut créer notre premier quadlet à partir d’un compose.yaml :

Créer un quadlet

Prenons un exemple concret : migrer mon Traefik depuis Docker Compose vers un quadlet Podman.

Le compose.yaml de départ :

services:
  traefik:
    image: "traefik:latest"
    container_name: "traefik"
    restart: always
    hostname: "traefik"
    networks:
      - traefik-net
    ports:
      - "443:443"
      - "80:80"
      - "8081:8080"
    environment:
      - "CF_API_EMAIL=cloudflare@une-tasse-de.cafe"
      - "CF_API_KEY=<token>" # à remplacer par un secret dans le quadlet
    volumes:
      - "/var/run/docker.sock:/var/run/docker.sock:ro"
      - "./config:/etc/traefik"
networks:
  traefik-net:
    external: true

podlet peut lire directement un compose.yaml et produire les fichiers .container correspondants, testons ça :

podlet compose /data/apps/traefik/compose.yaml

Error: 
   0: error converting compose file
   1: error reading compose file
   2: File `/data/apps/traefik/compose.yaml` is not a valid compose file
   3: cannot set `external` and fields other than `name`

Location:
   src/cli/compose.rs:219

Ah beh super, on dirait que podlet ne supporte pas les réseaux externes. Du coup il va falloir qu’on adapte le compose avant de l’envoyer dans podlet.

podlet a deux limitations concrètes sur les fichiers Compose réels :

  • Les réseaux avec external: true ne sont pas supportés (et les champs supplémentaires comme driver le sont encore moins).
  • Les chemins de volumes relatifs (./config) ne sont pas convertis en absolus.

Donc soit on modifie tous nos docker-compose, soit on va devoir créer un petit script compose2podlet avec yq qui règle les deux avant de passer à podlet :

#!/bin/bash
# compose2podlet - prépare un compose.yaml pour podlet
# Usage: compose2podlet <compose.yaml> [compose_dir]
set -euo pipefail

FILE="${1:-compose.yaml}"
COMPOSE_DIR="${2:-$(dirname "$(realpath "$FILE")")}"

# Lister les réseaux externes et en informer l'utilisateur
EXTERNAL_NETS=$(yq '.networks // {} | to_entries[] | select(.value.external == true) | .key' "$FILE" 2>/dev/null || true)
for NET in $EXTERNAL_NETS; do
    echo "[INFO] Réseau externe supprimé : $(echo "$NET" | tr -d '"')" >&2
done

# Construire un tableau JSON des réseaux externes pour jq
EXT_JSON=$(echo "$EXTERNAL_NETS" | tr -d '"' | jq -Rn '[inputs | select(length > 0)]')

yq -y \
  --argjson ext "$EXT_JSON" \
  --arg dir "$COMPOSE_DIR" \
  '.networks |= (. // {} | with_entries(select(.value.external != true)))
  | if (.networks // {}) == {} then del(.networks) else . end
  | .services |= with_entries(.value.networks = (.value.networks // [] | map(select(. as $n | $ext | index($n) == null))))
  | .services |= with_entries(if (.value.networks // []) == [] then del(.value.networks) else . end)
  | .services |= with_entries(.value.volumes = (.value.volumes // [] | map(
      if type == "string" and startswith("./") then $dir + "/" + .[2:]
      elif type == "object" and ((.source // "") | startswith("./")) then .source = $dir + "/" + .source[2:]
      else . end
    )))' \
  "$FILE"

Merci Claude pour cette commande yq que j’aurais jamais réussi à écrire tout seul 😅.

On l’enchaîne ensuite directement avec podlet via stdin :

root@Americano:/data/apps/traefik# compose2podlet /data/apps/traefik/compose.yaml | podlet compose -
[INFO] Réseau externe supprimé : traefik-net
# traefik.container
[Container]
ContainerName=traefik
Environment=CF_API_EMAIL=cloudflare@une-tasse-de.cafe CF_API_KEY=<token>
HostName=traefik
Image=traefik:latest
PublishPort=443:443
PublishPort=80:80
PublishPort=8081:8080
Volume=/var/run/docker.sock:/var/run/docker.sock:ro
Volume=/data/apps/traefik/config:/etc/traefik

[Service]
Restart=always

Tada, on peut d’ores et déjà voir que le fichier .container est presque prêt — il ne reste plus qu’à régler le problème du réseau externe, et à remplacer la variable d’environnement CF_API_KEY par un secret Podman.

Créer le réseau Podman

Un fichier .network dans /etc/containers/systemd/ suffit. Podman le crée automatiquement au démarrage du service :

# /etc/containers/systemd/traefik.network
[Network]
Label=app=traefik
Subnet=10.89.0.0/24
Gateway=10.89.0.1

Le subnet doit être distinct du réseau Docker (172.18.0.0/16 dans notre cas) et du réseau Podman par défaut (10.88.0.0/16).

Dès lors qu’on voudra qu’un quadlet soit dans ce réseau, il suffira de mettre Network=traefik.network dans la section [Container] du quadlet.

Gérer les secrets

Mettre un token API directement dans le fichier .container est une mauvaise idée : il se retrouve en clair dans un fichier versionnable. Pour éviter ça, Podman supporte nativement les secrets.

Créer le secret sur l’hôte :

echo -n "mon-token-cloudflare" | podman secret create cf_api_key -

Dans le .container, remplacer Environment=CF_API_KEY=<token> par :

Secret=cf_api_key,type=env,target=CF_API_KEY

Le secret est injecté comme variable d’environnement au démarrage du container, sans jamais apparaître dans le fichier de configuration.

podman secret ls
ID                         NAME        DRIVER      CREATED         UPDATED
521317ced429f4c50a67f0377  cf_api_key  file        16 seconds ago  16 seconds ago

(Optionnel) Permettre la communication avec les containers Docker

Dans mon cas, je suis en migration progressive et certains backends tournent encore sous Docker, donc Traefik doit pouvoir les joindre. C’est là que ça se corse 😅.

Docker bloque ce trafic via des règles anti-spoofing dans iptables raw PREROUTING : tout paquet destiné à un container Docker qui n’arrive pas via le bon bridge est droppé silencieusement. Et ça ne se voit pas dans DOCKER-USER — il faut activer le tracing nftables pour s’en apercevoir.

Il faut trois règles iptables :

iptables -I DOCKER-USER -s 10.89.0.0/24 -j ACCEPT
iptables -I DOCKER-USER -d 10.89.0.0/24 -j ACCEPT
iptables -t raw -I PREROUTING -s 10.89.0.0/24 -d 172.18.0.0/16 -j ACCEPT

Ces règles sont ajoutées automatiquement via ExecStartPre dans le quadlet, et nettoyées à l’arrêt via ExecStopPost.

Remarque

Ce problème disparaît complètement une fois tous les containers migrés vers Podman.


Voici le quadlet final, prêt à être déployé sur le serveur :

# /etc/containers/systemd/traefik.container
[Unit]
Description=Traefik reverse proxy
After=network-online.target

[Container]
Image=docker.io/library/traefik:latest
ContainerName=traefik
HostName=traefik
Network=traefik.network
PublishPort=80:80
PublishPort=443:443
PublishPort=8081:8081
Volume=/run/podman/podman.sock:/var/run/docker.sock:ro
Volume=/data/apps/traefik/config:/etc/traefik
Environment=CF_API_EMAIL=cloudflare@une-tasse-de.cafe
Secret=cf_api_key,type=env,target=CF_API_KEY

[Service]
Restart=always
# UNIQUEMENT SI on doit communiquer avec des containers Docker
ExecStartPre=bash -c 'iptables -C DOCKER-USER -s 10.89.0.0/24 -j ACCEPT 2>/dev/null || iptables -I DOCKER-USER -s 10.89.0.0/24 -j ACCEPT'
ExecStartPre=bash -c 'iptables -C DOCKER-USER -d 10.89.0.0/24 -j ACCEPT 2>/dev/null || iptables -I DOCKER-USER -d 10.89.0.0/24 -j ACCEPT'
ExecStartPre=bash -c 'iptables -t raw -C PREROUTING -s 10.89.0.0/24 -d 172.18.0.0/16 -j ACCEPT 2>/dev/null || iptables -t raw -I PREROUTING -s 10.89.0.0/24 -d 172.18.0.0/16 -j ACCEPT'
ExecStopPost=bash -c 'iptables -D DOCKER-USER -s 10.89.0.0/24 -j ACCEPT 2>/dev/null || true'
ExecStopPost=bash -c 'iptables -D DOCKER-USER -d 10.89.0.0/24 -j ACCEPT 2>/dev/null || true'
ExecStopPost=bash -c 'iptables -t raw -D PREROUTING -s 10.89.0.0/24 -d 172.18.0.0/16 -j ACCEPT 2>/dev/null || true'

[Install]
WantedBy=multi-user.target default.target

Deux différences notables par rapport au compose original : le socket Podman (/run/podman/podman.sock) à la place du socket Docker, et les secrets à la place des variables d’environnement en clair.

Une fois le quadlet prêt, on le copie dans /etc/containers/systemd/ pour qu’il soit pris en charge par systemd et on rafraîchit la configuration via un daemon-reload :

systemctl daemon-reload
systemctl start traefik
root@Americano:~# systemctl status traefik
● traefik.service - Traefik reverse proxy
     Loaded: loaded (/etc/containers/systemd/traefik.container; generated)
     Active: active (running) since Thu 2026-04-09 20:43:43 UTC; 3h 23min ago
 Invocation: 1d011222a69b453db386859ef8d91e6c
   Main PID: 2884022 (conmon)
      Tasks: 15 (limit: 38410)
     Memory: 34.2M (peak: 41M)
        CPU: 18.897s
     CGroup: /system.slice/traefik.service
             ├─libpod-payload-0ca53f3e91a677c23da1fb2b3f158f49afc15535fd69dca1413c6df00b5facc7
             │ └─2884035 traefik traefik
             └─runtime
               └─2884022 /usr/bin/conmon --api-version 1 -c 0ca53f3e91a677c23da1fb2b3f158f49afc15535fd69dca1413c6df00b5facc7 -u 0ca53f3e91a677c23da1fb2b3f158f49afc15535fd69dca1413c6df00b5facc7 -r /usr/bin/r>

Apr 09 20:43:43 Americano traefik[2884022]: 2026-04-09T20:43:43Z INF Starting provider *traefik.Provider
Apr 09 20:43:43 Americano traefik[2884022]: 2026-04-09T20:43:43Z INF Starting provider *acme.ChallengeTLSALPN
Apr 09 20:43:43 Americano traefik[2884022]: 2026-04-09T20:43:43Z INF Starting provider *docker.Provider

Traefik tourne maintenant géré par systemd via Podman — redémarrage automatique natif, logs dans journald, et plus besoin de daemon Docker pour lui.

Auto-update des images

Un des avantages de passer à Podman, c’est de pouvoir remplacer Watchtower par le mécanisme natif : podman auto-update.

Le principe : si un container est lancé avec le label io.containers.autoupdate=registry, Podman vérifie périodiquement si une nouvelle image est disponible sur le registry, et redémarre le service systemd correspondant avec la nouvelle image.

Activer l’auto-update sur un container

Il suffit d’ajouter le label dans le quadlet :

[Container]
Image=registry.access.redhat.com/ubi9-minimal:latest
Label=io.containers.autoupdate=registry

Et lorsque les conteneurs sont lancés, on peut vérifier que le label est bien pris en compte :

podman ps --filter "label=io.containers.autoupdate=registry" --format "table {{.Names}}\t{{.Image}}"
NAMES            IMAGE
systemd-mysleep  registry.access.redhat.com/ubi9-minimal:latest

Et pour lancer la mise à jour manuellement, il suffit juste de lancer la commande d’auto-update :

root@Americano:~# podman auto-update
            UNIT             CONTAINER                       IMAGE                                           POLICY      UPDATED
            mysleep.service  120522f9fb89 (systemd-mysleep)  registry.access.redhat.com/ubi9-minimal:latest  registry    false

Automatiser avec un timer systemd

Podman fournit un timer systemd prêt à l’emploi. Il suffit de l’activer :

systemctl enable --now podman-auto-update.timer

Par défaut, il tourne une fois par jour à minuit.


C’est parfait maintenant, on peut gérer toutes nos applications avec des quadlets Podman !

Il me manque quand même le truc que j’aime le plus avec Kubernetes : le GitOps. Je n’ai pas vraiment envie de devoir me connecter au serveur chaque fois que je déploie un nouveau service ou que je modifie un container existant 😕. C’est un total no-go pour mon lab.

C’est pour cela qu’on va rajouter un nouvel outil dans la stack : Materia, un outil GitOps pensé spécifiquement pour les quadlets Podman.

Information

J’ai vu qu’il en existait plusieurs autres comme quad-ops, fetchit ou quadit. Je ne les ai pas tous testés, mais Materia a l’air d’être celui qui supporte le plus de fonctionnalités.

GitOps avec Materia

Gérer ses quadlets à la main sur le serveur, c’est bien. Les versionner dans un repo Git et les déployer de façon déclarative, c’est mieux. C’est ce que propose Materia, un outil GitOps pensé spécifiquement pour les quadlets Podman.

Le principe de Materia est simple : on décrit l’état désiré dans un repo Git, et Materia synchronise le serveur pour correspondre à cet état.

Si vous voulez en savoir un peu plus sur ce qu’est le GitOps, j’ai écrit un article sur ArgoCD dans lequel je détaille dans la première partie les concepts clés. Vous pouvez retrouver le chapitre ici.

Installation de Materia

Materia est disponible en binaire sur GitHub, c’est ce qui va tourner sur le serveur pour faire le lien entre le repo Git et les quadlets Podman. On peut l’installer en deux commandes :

curl -fsSL https://github.com/stryan/materia/releases/download/v0.6.4/materia-amd64 -o /usr/local/bin/materia
chmod +x /usr/local/bin/materia

Information

Il est aussi possible de l’installer en mode conteneur via podman run.

podman run --name materia --rm \
	--hostname <system_hostname> \
	--network host \
	--security-opt label=disable \ # optional, depending on OS security settings
	-v /run/dbus/system_bus_socket:/run/dbus/system_bus_socket \ # needed to manage systemd units
	-v /run/podman/podman.sock:/run/podman/podman.sock \ # needed to get container status
	-v /var/lib/materia:/var/lib/materia \ # Where materia stores its source cache and component data
	-v /etc/containers/systemd:/etc/containers/systemd \ # needed to install Quadlets
	-v /usr/local/bin:/usr/local/bin \ # customizable, change to where ever you want scripts to be installed to
	-v /etc/systemd/system:/etc/systemd/system \ # Needed to manage services, can also use /usr/local/lib/systemd/system/
	-v /etc/materia/known_hosts:/root/.ssh/known_hosts:ro \ #Optional, used for git+ssh checkouts
	-v /etc/materia/key.txt:/etc/materia/key.txt \ #Optional, used for age decryption
	-v /etc/materia/materia_key:/etc/materia/materia_key \ # Optional, used for git+ssh checkouts
	--env MATERIA_AGE__KEYFILE=/etc/materia/key.txt \
	--env MATERIA_SOURCE__KIND="git" \
	--env MATERIA_SOURCE__URL=https://github.com/stryan/materia_example_repo \
	ghcr.io/stryan/materia:stable update

Je vais continuer à m’en servir en binaire pour le moment, mais ce n’est pas exclu que je passe à la version conteneur à terme.

Structure du repo

Maintenant, on va créer le repo Git qui va stocker nos quadlets et notre configuration. Voici la structure que j’ai choisie, inspirée de l’exemple officiel de Materia :

gitops-repo/
├── MANIFEST.toml          # Associe les composants aux hôtes
├── attributes/
│   └── Americano.toml     # Attributs spécifiques à l'hôte
└── components/
    └── nginx/
        ├── MANIFEST.toml  # Déclare le service systemd à gérer
        └── nginx.container

Le MANIFEST.toml racine déclare quels composants sont déployés sur quel hôte (identifié par son hostname) :

[Hosts.Americano]
components = ["nginx"]

Le MANIFEST.toml du composant déclare le service systemd associé :

[[Services]]
Service = "nginx.service"

Et le fichier .container est un quadlet standard, placé directement dans le composant :

# components/nginx/nginx.container
[Unit]
Description=Nginx
After=network-online.target traefik.service

[Container]
Image=docker.io/library/nginx:latest
ContainerName=nginx
HostName=nginx
Network=traefik.network
Label=traefik.enable=true
Label=traefik.http.routers.nginx.entrypoints=secure
Label=traefik.http.routers.nginx.rule=Host(`nginx.americano.thoughtless.eu`)
Label=traefik.http.routers.nginx.tls.certresolver=letsencrypt
Label=traefik.http.services.nginx.loadbalancer.server.port=80

[Service]
Restart=always

[Install]
WantedBy=multi-user.target default.target

Les attributs par hôte

Les attributs permettent de paramétrer les composants différemment selon l’hôte, sans dupliquer les fichiers .container. C’est particulièrement utile quand on gère plusieurs serveurs avec des chemins ou des configurations légèrement différents.

On les déclare dans attributes/<hostname>.toml :

# attributes/Americano.toml
[components.nginx]
webRoot = "/data/www"

Et dans le fichier .container, on les référence avec la syntaxe Go template — en renommant le fichier .container.gotmpl :

# components/nginx/nginx.container.gotmpl
[Container]
Image=docker.io/library/nginx:latest
Volume={{ .webRoot }}:/usr/share/nginx/html:ro

Remarque

Les fichiers avec extension .gotmpl sont traités comme des templates Go par Materia avant d’être déposés sur le serveur. Les fichiers sans cette extension sont copiés tels quels.

On peut aussi définir des valeurs par défaut dans attributes/vault.toml, qui s’appliquent à tous les hôtes et peuvent être surchargées hôte par hôte :

# attributes/vault.toml
[components.nginx]
webRoot = "/srv/www"

L’ordre de priorité est : attributs hôte > vault global.

Configuration sur le serveur

Materia se configure via variables d’environnement. On les stocke dans un fichier pour ne pas les retaper à chaque fois :

cat > /etc/materia/env << 'EOF'
MATERIA_SOURCE__KIND=git
MATERIA_SOURCE__URL=https://github.com/qjoly/quadlet.coffee.gitops
MATERIA_GIT__BRANCH=main
MATERIA_ATTRIBUTES=file
MATERIA_FILE__BASE_DIR=attributes
EOF

Avertissement

La variable pour la branche est MATERIA_GIT__BRANCH, Materia cherche master par défaut — sans cette variable, il échoue silencieusement si le repo utilise main.

Déployer

On commence par afficher le plan de ce que Materia va faire, sans rien appliquer :

env $(cat /etc/materia/env | xargs) materia plan
Plan:
1. (nginx) Install Component nginx
2. (nginx) Install Container nginx.container
3. (nginx) Install Manifest MANIFEST.toml
4. (root) Reload Host
5. (nginx) Start Service nginx.service

Si le plan correspond à ce qu’on attend, on applique :

env $(cat /etc/materia/env | xargs) materia update

Materia copie le quadlet dans /etc/containers/systemd/, fait un systemctl daemon-reload, et démarre le service. Tout ce qu’on faisait à la main, automatisé.

Automatiser avec une crontab

Pour que le serveur se synchronise tout seul dès qu’on pousse sur le repo, on ajoute une crontab :

crontab -e
*/15 * * * * env $(cat /etc/materia/env | xargs) materia update >> /var/log/materia.log 2>&1

Toutes les 15 minutes, Materia récupère le dernier état du repo et applique les changements si nécessaire. Si rien n’a changé, l’opération est idempotente — il ne touche à rien.

Pour ajouter un nouveau service, il suffit de créer le composant dans le repo, commiter, et attendre la prochaine exécution de la crontab. C’est pas aussi confortable qu’un webhook qui lancerait le materia update automatiquement, mais c’est déjà un énorme gain de confort par rapport à la gestion manuelle.

Conclusion

Sans forcément faire du Kubernetes pour tout, on peut déjà faire du GitOps sur des machines individuelles avec Podman et Materia. C’est une stack légère, flexible, et qui s’intègre bien avec systemd.

Je regrette quand même un peu mon Talos et mes Helms, mais ça reste une très bonne solution durable et facile à configurer.

Bon kawa ! ☕️