Aller au contenu
← BlogPlateforme· 6 min de lecture

Des stacks docker-compose déployées à la main aux services Dokploy générés depuis un manifest : le chemin, les choix et les pièges.

InfraOps
Le fichier .deploy/manifest.yaml du repo cvforge sur GitHub, avec ses commentaires sur les volumes et le mode compose

Pendant longtemps, chacune de mes apps se déployait à sa façon. Jobspark avait ses pipelines et son dossier Terraform, Jémima les siens, et deux services tournaient en docker-compose hors de tout panel. Les volumes avaient des préfixes hérités de noms de projet abandonnés. Une des apps n’avait aucune sauvegarde hors du serveur.

Ça marche, jusqu’au jour où il faut restaurer une base, ajouter une machine ou simplement se souvenir de quel volume est le bon. J’ai donc mis la plateforme sous code, avec une règle simple : une app, un fichier YAML.

Décrire, pas déployer

Chaque app pose un fichier .deploy/manifest.yaml à côté de son code. Elle y décrit ce qu’elle exécute : ses apps, ses workers, ses bases, ses caches, ses environnements et leurs domaines. Elle ne dit rien de la façon de le déployer.

La plateforme, dans un repo à part, lit ce fichier et crée le reste dans Dokploy : un projet, un environnement par étape, un service par composant, les domaines en HTTPS, les enregistrements DNS chez Cloudflare et les sauvegardes vers R2. Le repo de l’app ne contient ni Terraform ni script de déploiement.

Le repo de l’app ne contient que son manifest. Le moteur vit dans le repo d’infra, et le workflow réutilisable le récupère à chaque déploiement.

Un vrai manifest : celui de ce site

Voici, à peine raccourci, le manifest qui déploie le site que vous lisez. Quatre composants : le front Next.js, le CMS Payload, un MinIO pour les médias et une base Postgres.

.deploy/manifest.yaml (extrait)
apiVersion: deploy/v1
name: portfolio
server: vps20
mode: services

secrets: [DB_PASSWORD, PAYLOAD_SECRET, REVALIDATE_SECRET, CMS_API_KEY,
          MINIO_ACCESS_KEY, MINIO_SECRET_KEY, ADMIN_EMAIL, ADMIN_PASSWORD, …]
lockedSecrets: [PAYLOAD_SECRET, MINIO_ACCESS_KEY, MINIO_SECRET_KEY]

apps:
  web:
    image: ghcr.io/josias-koffi/portfolio-web
    registry: ghcr                      # paquet privé
    port: 3000
    domain: web
    healthcheck: /api/health
    uses: [cms]                         # CMS_URL=https://<domaine cms>
    vars:
      SITE_URL: https://${domains.web}
    resources: { cpus: 0.5, memory: 512m }
  cms:
    image: ghcr.io/josias-koffi/portfolio-cms
    port: 3000
    domain: cms
    healthcheck: /api/health
    uses: [db, web]                     # DATABASE_URL, WEB_URL
    vars:
      S3_ENDPOINT: https://${domains.media}
      S3_ACCESS_KEY: ${secrets.MINIO_ACCESS_KEY}
    resources: { cpus: 1, memory: 1g }
  media:
    image: ghcr.io/josias-koffi/minio
    tag: RELEASE.2025-04-22T22-12-26Z
    port: 9000
    domain: media
    volumes: { data: /data }            # <volumePrefix>_data, sauvegardé chaque jour

databases:
  db: { type: postgres, version: 16-alpine, database: portfolio, user: portfolio }

environments:
  production:
    branch: main
    deploy: manual                      # un push sur main ne déploie jamais
    domains: { web: koklo.dev, cms: cms.koklo.dev, media: media.koklo.dev }
  staging:
    branch: develop                     # deploy: auto (défaut)
    domains: { web: portfolio-staging.koklo.dev, cms: cms-staging.koklo.dev,
               media: media-staging.koklo.dev }
    overrides:
      apps: { cms: { resources: { memory: 768m } } }

Quelques détails comptent. uses relie les composants : le CMS reçoit DATABASE_URL parce qu’il utilise db, et WEB_URL parce qu’il utilise web. Les valeurs de vars sont des modèles, avec ${domains.media} ou ${secrets.MINIO_ACCESS_KEY}, résolus pour chaque environnement. overrides change une seule valeur pour un seul environnement, ici la mémoire du CMS en staging.

Tableau des clés d’un composant dans la référence du manifest : image, tag, registry, port, domain, healthcheck, uses, volumes, resources
Un composant tient en une quinzaine de clés, toutes documentées et vérifiées par le schéma JSON.

Le fichier est validé par un schéma JSON publié dans le repo. Une ligne en tête du manifest suffit pour avoir la complétion dans l’éditeur.

Ce que le fichier devient

Ce que chaque entrée du manifest produit dans un environnement

Chaque entrée devient un ou plusieurs objets Dokploy. Une app avec un domain donne une application, un domaine HTTPS avec Let’s Encrypt et un enregistrement A. Un volumes donne un montage et une sauvegarde quotidienne. Une base donne un service Postgres et sa sauvegarde. Rien de tout cela n’est à configurer : les sauvegardes sont actives par défaut, et les désactiver demande une raison écrite que le schéma exige.

Pour le montrer, j’ai rejoué le plan du staging de ce site dans une copie du module, avec le provider simulé des tests. Aucune connexion à Dokploy n’est faite :

infra/modules/dokploy-app

On retrouve les quatre composants du manifest, plus une ressource discrète, locked_secrets, sur laquelle je reviens plus bas. Le détail des sauvegardes montre les conventions : un préfixe par app et par environnement, la base à 3h, les volumes à 4h, et sept copies gardées en staging contre trente-cinq en production.

infra/modules/dokploy-app

Le passage du module qui traduit uses est court. Une base donne six variables, une autre app donne son URL publique :

modules/dokploy-app/services.tf (extrait)
contains(keys(local.databases), u) ? [
  for line in [
    ["HOST", local.db_hosts[u]],
    ["PORT", tostring(local.default_ports[local.databases[u].type])],
    ["NAME", try(local.databases[u].database, "")],
    ["USER", local.databases[u].user],
    ["PASSWORD", local.db_password[u]],
    ["URL", format("%s://%s:%s@%s:%d/%s", …)],
  ] : "${try(local.databases[u].envPrefix, "DATABASE")}_${line[0]}=${line[1]}"
] : contains(keys(local.components), u) ? [
  # App to app goes through the public domain: an app_name is only
  # known after its own create […]
  "${upper(replace(u, "-", "_"))}_URL=https://${local.domains[local.components[u].domain]}",
] : ["# unknown uses entry: ${u}"]

Le commentaire dit pourquoi une app en appelle une autre par son domaine public : le nom interne d’un service n’est connu qu’après sa création. C’est une limite assumée, notée dans la doc.

Valider avant de planifier

Un manifest faux ne doit jamais arriver jusqu’au plan. Le validateur contrôle d’abord le schéma, puis la cohérence : chaque uses nomme un composant connu, chaque domaine existe dans chaque environnement, chaque base a son secret de mot de passe déclaré.

infra

Le deuxième fichier contient trois erreurs que le schéma seul ne verrait pas. Le troisième est refusé dès le schéma. Le même script tourne en local, dans make test et en première étape de chaque déploiement.

Les règles de déploiement vivent dans le manifest

Au début, les règles de déclenchement étaient dans les workflows de chaque app. Je les ai déplacées dans le manifest : chaque environnement dit sa branche, et s’il se déploie à chaque push (deploy: auto, le défaut) ou seulement à la main (deploy: manual).

Tableau événement / effet du guide de déploiement : push en deploy auto, push en deploy manual, Run workflow, image_tag, plan_only
Les règles de déclenchement tiennent en deux clés par environnement, et ce tableau dit tout ce qui peut arriver.
Un push sur develop, du repo de l’app jusqu’à Dokploy

Le workflow de l’app appelle d’abord resolve.yml, qui lit le manifest et répond trois choses : faut-il déployer, vers quel environnement, avec quel tag. Le tag est le SHA court du commit. L’app construit ses images si on le lui demande, puis appelle deploy.yml, qui fait le reste.

Le fichier templates/app-deploy.yml sur GitHub : entrées environment, image_tag et plan_only du workflow_dispatch
Le workflow à copier dans chaque app : aucune règle, trois entrées manuelles pour promouvoir, revenir en arrière ou seulement planifier.

En production, ce site est en deploy: manual. Un push sur main ne fait donc rien d’autre que lire le manifest :

Run GitHub Actions d’un push sur main : seul resolve s’exécute, notice « production is deploy: manual »
Un vrai push sur main : resolve lit le manifest, voit `deploy: manual` et s’arrête en 43 secondes, sans rien construire.

Pour mettre en prod, je relance le workflow depuis main avec le tag qui tourne déjà en staging. C’est une promotion, pas un nouveau build. Le rollback est le même geste avec un tag plus ancien.

Les garde-fous

Un fichier déclaratif a un danger : supprimer une ligne peut supprimer une base. Le script de déploiement enregistre donc le plan, le relit en JSON et refuse deux choses avant l’apply.

  • La destruction d’une ressource avec état : une base, un cache, un compose ou un montage. Retirer un environnement passe par un workflow purge-env protégé, qui archive les volumes d’abord.
  • Le changement d’un secret verrouillé. Postgres ne lit son mot de passe qu’à la création du volume. Changer DB_PASSWORD ensuite met à jour la variable, pas le rôle, et l’app ne se connecte plus. Le state garde l’empreinte SHA-256 de chaque secret verrouillé, et un plan qui la change est refusé. C’est la ressource locked_secrets du plan plus haut.

Sur ce site, PAYLOAD_SECRET est verrouillé parce qu’il signe les sessions et l’index des clés API, et les clés root de MinIO parce que MinIO chiffre sa configuration avec. Une rotation volontaire reste possible, nom par nom, avec ALLOW_SECRET_CHANGE.

Reprendre l’existant

Le plus délicat n’a pas été d’écrire le module, mais de reprendre des stacks en production sans rien casser. Avant de toucher quoi que ce soit, j’ai fait des dumps des quatre bases avec leurs comptes de lignes, et je les ai copiés hors du serveur.

Le fichier .deploy/manifest.yaml du repo cvforge sur GitHub, avec ses commentaires sur les volumes et le mode compose
Le manifest de Jobspark : en tête, la carte des volumes réellement montés, écrite avant de toucher au moindre préfixe.

Puis j’ai vérifié le comportement de Dokploy sur un projet jetable. Changer l’environnement d’un compose le déplace sur place, sans même redémarrer le conteneur. En revanche, supprimer un compose supprime ses volumes nommés, sauf s’ils sont déclarés external.

Les anciens states ont été libérés avec tofu state rm, jamais destroy. Les sauvegardes R2 existantes ont été importées telles quelles, avec leur arborescence historique, pour ne pas perdre l’historique.

Et le jour où le serveur tombe

Tout cela n’a de sens que si l’on peut repartir de zéro. La procédure est écrite dans le guide des sauvegardes, et elle ne demande rien qui ne soit dans le repo ou dans R2.

  1. Un nouveau serveur : son IP dans l’inventaire Ansible, dans platform/nodes.yaml et dans le DNS du panel.
  2. make setup-manager installe Docker et Dokploy, à la version figée. Le rôle refuse de réinstaller un Dokploy qui tourne.
  3. Dokploy se restaure depuis sa propre sauvegarde quotidienne dans R2 : sa base et /etc/dokploy.
  4. make platform-apply, puis le workflow de chaque app, staging d’abord : les services sont recréés depuis les manifests.
  5. Les bases et les volumes se restaurent depuis R2. Le DNS suit tout seul, puisque les enregistrements A pointent vers l’IP du serveur déclaré.

Le même fichier nodes.yaml sert à ajouter une machine. Un second serveur se prépare avec un playbook, se déclare en une ligne, et une app le choisit avec server:. Il garde son propre Traefik, et Dokploy le pilote en SSH.

Ce que ça change au quotidien

  • Le staging se déploie à chaque push sur develop, la production d’un clic, avec un tag déjà testé.
  • Chaque base et chaque volume est sauvegardé tous les jours vers R2, dans un bucket par environnement : le staging n’a pas accès aux sauvegardes de prod.
  • Un state par environnement : un apply de staging ne peut ni lire ni modifier la prod.
  • Un serveur perdu se reconstruit depuis le repo et les sauvegardes, selon une procédure écrite dans la doc.
  • Une nouvelle app, c’est un manifest, un workflow copié et ses secrets dans les environnements GitHub.

Il reste des limites. Il n’y a pas de réseau entre serveurs, donc une app et ses dépendances vivent sur la même machine. Les preview deployments par PR ne sont pas encore modélisés. Mais la question « comment cette app est-elle déployée ? » a maintenant une seule réponse, et elle tient dans un fichier que je relis en revue de code comme le reste.

#dokploy#terraform#opentofu#iac#github-actions#backups#cloudflare