Antoine's computer
This article is not available in English. You are reading the French version.
Un preview deployment créé automatiquement par Dokploy à l'ouverture d'une PR, avec son URL dédiée.

Gérer un preview deployment Dokploy sans impacter la BDD de production

Comment tester ses migrations sur un preview deployment Dokploy sans toucher à la base de production, grâce à un instantané SQLite isolé créé au démarrage.

Last updated: June 20, 2026 · 8 min read


Point de vocabulaire pour toute la suite : je nomme slot le dossier dédié à un preview deployment, tel que /data/preview/<slot>/.

Dokploy, je m’en sers pour déployer mes petits side projects, et même le portfolio depuis lequel vous êtes en train de lire cet article. Si vous ne connaissez pas, c’est un PaaS open-source : il vient rendre le déploiement d’applications agréable (build, auto-deploy, reverse proxy, certificats, variables d’environnement, etc.) et offre des fonctionnalités utiles au développement, comme les preview deployments, le sujet du jour. L’alternative la plus connue est Coolify, un des plus anciens projets du genre.

Un preview deployment est une instance de l’application déployée automatiquement selon des conditions particulières, comme la création d’une pull request. Dokploy, en détectant l’ouverture d’une nouvelle PR, va alors créer un nouveau déploiement sur un sous-domaine du domaine wildcard configuré, ce qui permet de facilement venir tester les changements. Un preview deployment créé automatiquement par Dokploy à l&#x27;ouverture d&#x27;une PR, avec son URL dédiée

Le problème

Les preview deployments sont facilement configurables, mais voilà le hic : ils réutilisent les mêmes volumes que le déploiement de production, sans possibilité de changer ça. Comme ma BDD SQLite vit dans un volume persistant monté sur /data, chaque preview monte ce même volume et se retrouve donc avec le fichier de prod directement sous la main… Ça pose donc problème dès qu’on veut tester une migration de BDD : dans mon cas, les migrations Alembic tournent au démarrage du conteneur, donc une preview qui ouvrirait la base de prod et lancerait alembic upgrade head réécrirait le schéma de production. C’est pas bon du tout. C’est une limitation connue il me semble, une solution générique serait de cloner le volume de prod pour chaque preview, ainsi chacune fonctionnerait dans un environnement isolé, mais évidemment ce n’est pas forcément ce que l’on souhaite, ni forcément possible, en plus d’être coûteux en ressources.

Dans mon cas, j’ai expérimenté sur une application minimaliste, en l’occurrence un simple front-end Python avec une BDD SQLite, et trouvé une solution adéquate. Même si ça ne conviendra pas à toutes les situations, et qu’il faudra adapter le code selon la stack utilisée, je pense que l’idée peut s’appliquer à pas mal de cas : principalement des petits projets sans grande envergure et avec une seule base assez légère.

Ce qui me déplaît, c’est la présence de tout ce code applicatif spécifique au processus de développement (ici, les preview deployments) qui se retrouve directement empaqueté dans l’application de production. Évidemment on pourrait définir un BUILD_ARG Docker pour n’inclure ce code qu’en build PREVIEW, mais je pense que ça serait overkill dans la plupart des projets. Je préfère un simple if au démarrage.

L’idée

Indiquer à l’application comment elle doit se comporter au démarrage vis-à-vis de l’environnement dans lequel elle se trouve. Pour cela, une variable d’environnement IS_PREVIEW. Si elle vaut true, l’application prend un instantané isolé des données de prod (la BDD, dans mon exemple) dans son propre répertoire de travail, puis laisse la main au service applicatif, qui exécutera ses migrations et autres opérations sur cette copie isolée, sans jamais toucher la prod.

Un détail qui compte : je rends IS_PREVIEW obligatoire et explicite. Pas de valeur par défaut, ainsi si la variable est absente, l’application refuse de démarrer. Et puis perso, je sais pas vous, mais j’aime pas les valeurs par défaut, dans à peu près toutes les situations. Bref selon moi mieux vaut un démarrage qui échoue franchement, que de confondre une preview avec la prod par simple erreur ou oubli.

Au démarrage : isoler avant tout le reste

L’ordre est crucial. On veut basculer sur l’instantané avant d’ouvrir la base et avant de lancer les migrations, sinon l’engine ou Alembic auront déjà touché la prod.

def main() -> None:
    db_path = os.environ.get("DB_PATH")
    is_preview = require_bool_env("IS_PREVIEW")

    if is_preview:
        # Bascule sur l'instantané isolé AVANT d'ouvrir l'engine et de migrer.
        db_path = prepare_preview(db_path)

    # la copie en preview, la prod sinon
    engine = open_engine(db_path)
    # alembic upgrade head, sur la copie
    apply_migrations(engine)
    # let the show begin!
    start_app(engine)

Il me faut aussi un slot par preview, pour que plusieurs previews puissent tourner en même temps sans se marcher dessus. Son nom, je le dérive d’une variable que Dokploy injecte déjà : DOKPLOY_DEPLOY_URL. C’est l’URL unique générée pour chaque déploiement, j’en fais un hash hexa et on est bons :

def slot_name(deploy_url: str) -> str:
    return hashlib.sha256(deploy_url.encode()).hexdigest()[:16]

C’est cool, car c’est déterministe : un même déploiement ne change pas d’URL entre les rebuilds, donc le slot est préservé d’un redémarrage à l’autre. En plus, le hash hexa me donne un nom sans caractères spéciaux, donc c’est safe pour le filesystem. Et ça ne dépend d’aucune saisie manuelle, qui ruinerait tout l’intérêt des déploiements automatisés. On se retrouve avec un slot dédié par preview, type /data/preview/<slot>/.

D’où un garde-fou : si IS_PREVIEW=true mais que DOKPLOY_DEPLOY_URL est absent ou vide, je raise une jolie exception avant de démarrer, ouf la prod ne risque rien !

L’instantané : copier sans jamais toucher la prod

C’est le cœur de la solution. Copier une base SQLite à chaud n’est pas trivial. Si le fichier est écrit pendant la copie, un simple cp peut produire un fichier corrompu, ou incohérent, en tout cas quelque chose d’incertain et de non souhaitable. La solution ? Ouvrir la BDD en lecture seule et utiliser l’instruction VACUUM INTO. D’ailleurs avant de faire la copie, une bonne idée est de vérifier qu’il reste assez d’espace disque, car bon on bosse quand même sur le même volume que la prod. On se retrouve donc avec une logique qui ressemble à ça :

def snapshot_database(source: Path, dest: Path) -> None:
    tmp_dest = dest.with_name(dest.name + ".tmp")
    # VACUUM INTO requiert une cible inexistante
    tmp_dest.unlink(missing_ok=True)
    try:
        # Source en lecture seule : la prod est hors de tout danger. isolation_level=None
        # (autocommit) car VACUUM ne peut pas tourner dans une transaction ouverte
        conn = sqlite3.connect(f"file:{source}?mode=ro", uri=True, isolation_level=None)
        try:
            # Soyons patients, attendons notre tour ! C'est toujours mieux que de se prendre une exception si la BDD est busy
            conn.execute("PRAGMA busy_timeout=30000")
            conn.execute("VACUUM INTO ?", (str(tmp_dest),))
        finally:
            conn.close()
        # Écriture atomique vers le fichier cible final, c'est-à-dire en une seule opération
        # système : pas de risque de fichier tronqué si le conteneur crash ou manque d'espace disque
        os.replace(tmp_dest, dest)
    except Exception:
        tmp_dest.unlink(missing_ok=True)
        raise

Et si la base de prod n’existe pas encore, alors on ne peut pas la copier. Dans ce cas, les migrations construiront le schéma de zéro, dans le slot.

Persister ou repartir de zéro ?

Au début, je reprenais un instantané tout frais de la prod à chaque redéploiement. Pratique pour tester une migration isolée… mais frustrant dès qu’on itère : impossible de conserver un état push après push.

Puisque le nom du slot est déterministe dans notre cas (le hash de DOKPLOY_DEPLOY_URL, lui-même préservé par Dokploy), le slot survit déjà aux redéploiements : il suffit de choisir de l’écraser ou non. Par défaut, le slot existant est réutilisé, et alembic upgrade head applique simplement les nouvelles migrations par-dessus.

Parfois, on peut avoir besoin de repartir proprement, d’un état sain, donc de refaire une copie de la base la base de prod. Ça peut arriver après avoir push une mauvaise migration, ou avoir inséré trop de données de test, par exemple. Plutôt que de supprimer le preview deployment à la main, une variable d’environnement PREVIEW_RESET fait le travail : si elle vaut true, on remplace le slot par un nouvel instantané pour ce déploiement-là. On la repasse à false ensuite et on repart en mode incrémental.

Le cleanup

Chaque preview laisse derrière elle une copie complète de la base, et potentiellement d’autres fichiers additionnels, qu’il faut donc nettoyer lorsqu’ils ne sont plus nécessaires, c’est-à-dire lorsque le preview deployment n’existe plus (PR mergée, fermée, peu importe).

Le réflexe serait de le faire à l’arrêt de l’application, en utilisant par exemple atexit en Python, mais ce n’est pas une solution parfaite : le hook ne se déclencherait pas en cas de SIGKILL, ou tout simplement d’un autre problème comme la suppression forcée du conteneur ou un reboot de la machine hôte. J’ai donc choisi de faire le cleanup au démarrage.

Pour cela, je parcours chaque slot et je détermine sa date de dernière activité. Si elle est plus vieille qu’un certain seuil, c’est qu’il n’y a pas eu d’écriture en base depuis un bon moment : je considère le slot comme stale et je supprime ses fichiers.

Détail qui a toute son importance : cette “date de dernière activité”, c’est le mtime (modified time) du fichier le plus récent à l’intérieur du dossier — et surtout pas le mtime du dossier lui-même, car ce dernier ne change que lorsqu’on y ajoute ou supprime des fichiers, pas quand on réécrit un fichier existant.

Je suis pas fan de tout ce mécanisme, mais pour le moment ça fonctionne vachement bien !

En résumé

C’est une solution maison qui marche bien dans mon cas, mais pour un projet plus ambitieux, avec d’autres utilisateurs que moi, je me pencherais plutôt sur la fonctionnalité de branching de Neon, qui semble répondre exactement à cette problématique côté Postgres. Pour un petit projet SQLite, en revanche, quelques dizaines de lignes au démarrage suffisent à tester ses migrations en preview l’esprit tranquille.