← Blogue

Déplacer une application Django de Heroku vers DigitalOcean

Railyard

13 août 2026 · 11 min de lecture

Suivez ça sur votre propre serveur. Commencer gratuitement, sans carte

Les dynos, config vars et heroku pg:backups de Heroku cachent une vraie quantité de travail d’infrastructure derrière une CLI, c’est exactement pourquoi partir est plus compliqué qu’un déploiement ailleurs. Une application Django sur Heroku a besoin d’un serveur qui reste en ligne, d’un gestionnaire de processus qui fait tourner gunicorn, d’un worker Celery si l’application en a un, d’une base de données Postgres avec les vraies données dedans, de chaque config var mappée à un vrai environnement, et d’un domaine basculé sans écart où ni l’un ni l’autre serveur n’a les données actuelles. Ce guide fait tout ça contre un droplet DigitalOcean, dans Docker, et termine avec la même application qui tourne sur une infrastructure que vous contrôlez.

Ce qu’il vous faut

  • Un droplet DigitalOcean. Le plan Basic à 1 vCPU / 2 Go de RAM fait tourner Postgres, gunicorn et Caddy ensemble confortablement et coûte actuellement 12 $ par mois ; le plan à 1 Go (6 $ par mois) devient trop serré une fois que Postgres tourne aussi à côté.
  • La CLI Heroku, déjà authentifiée, pour rapatrier les config vars et un dump de base de données.
  • Un domaine actuellement pointé vers Heroku, avec accès pour changer son DNS.
  • Le code source de l’application dans un dépôt Git, avec un requirements.txt que Heroku utilise déjà pour builder. Ce guide utilise Django 6.1.2, gunicorn 26.2.0 comme serveur WSGI, et psycopg2-binary 2.9.13 pour parler à Postgres.
  • Docker et le plugin Compose, soit localement pour tester le build d’abord, soit sur le serveur.

Étape 1 : faire l’inventaire de ce qui tourne réellement sur Heroku

L’API de Heroku n’expose pas votre dépôt directement, donc cette étape est manuelle, et la sauter est la façon la plus facile de manquer quelque chose sur le nouveau serveur. Rapatriez les trois choses dont vous avez besoin :

heroku config -a your-app-name > heroku-config.txt
heroku ps -a your-app-name
heroku addons -a your-app-name

heroku ps montre votre formation actuelle. Pour une application Django, c’est typiquement un dyno web qui fait tourner gunicorn, et peut-être un dyno worker qui fait tourner Celery si l’application traite des jobs en arrière-plan. heroku addons montre ce qui est réellement provisionné, presque toujours heroku-postgresql sur un certain plan, qui a besoin d’un conteneur Postgres équivalent sur le nouveau serveur.

Étape 2 : créer le droplet

Depuis le tableau de bord DigitalOcean, créez un nouveau droplet : choisissez l’image Ubuntu 24.04 LTS, le plan Basic 2 Go, une région proche de vos utilisateurs, et votre clé SSH. Une fois qu’il tourne, notez son adresse IP publique et pointez l’enregistrement A de votre domaine vers elle :

myproject.example.com.  A  203.0.113.10

La propagation DNS peut prendre de quelques minutes à quelques heures ; vous pouvez continuer à configurer le serveur en attendant. Connectez-vous en SSH en tant que root et installez Docker :

ssh root@203.0.113.10
curl -fsSL https://get.docker.com | sh
apt-get install -y docker-compose-plugin

Vérifiez que ça a fonctionné :

docker compose version

Étape 3 : préparer l’application Django pour la production

Le Procfile de Heroku et ses dynos éphémères cachent quelques réglages que Django a besoin de fixer explicitement sur un serveur que vous gérez vous-même. Dans settings.py :

import os

DEBUG = False
ALLOWED_HOSTS = os.environ.get("DJANGO_ALLOWED_HOSTS", "").split(",")

DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.postgresql",
        "NAME": os.environ["POSTGRES_DB"],
        "USER": os.environ["POSTGRES_USER"],
        "PASSWORD": os.environ["POSTGRES_PASSWORD"],
        "HOST": "db",
        "PORT": "5432",
    }
}

STATIC_URL = "/static/"
STATIC_ROOT = "/app/staticfiles"

Si l’application lit actuellement DATABASE_URL comme une seule chaîne via django-environ ou dj-database-url sur Heroku, ça continue de fonctionner ; les variables d’environnement ci-dessus sont juste la version la plus simple une fois que la base de données est un conteneur nommé db plutôt que l’hôte Postgres de Heroku. Confirmez que les versions exactes épinglées dans requirements.txt correspondent à ce que ce guide suppose :

django==6.1.2
gunicorn==26.2.0
psycopg2-binary==2.9.13

Étape 4 : écrire le Dockerfile

FROM python:3.13-slim

ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .
RUN python manage.py collectstatic --noinput

CMD ["sh", "-c", "python manage.py migrate --no-input && gunicorn myproject.wsgi:application --bind 0.0.0.0:8000 --workers 3"]

Remplacez myproject.wsgi par le vrai package de votre projet. Faire tourner migrate avant que gunicorn démarre veut dire que chaque déploiement applique les migrations en attente automatiquement, ce qui remplace l’étape de release que Heroku faisait tourner (une ligne release: python manage.py migrate dans le Procfile, très probablement). Trois workers gunicorn sont un bon point de départ pour un droplet de 2 Go ; surveillez la mémoire sous charge et ajustez.

Étape 5 : écrire docker-compose.yml

services:
  db:
    image: postgres:17-alpine
    restart: unless-stopped
    environment:
      POSTGRES_DB: myproject
      POSTGRES_USER: myproject
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U myproject"]
      interval: 5s
      timeout: 5s
      retries: 5

  web:
    build: .
    restart: unless-stopped
    env_file: .env
    depends_on:
      db:
        condition: service_healthy
    volumes:
      - static_files:/app/staticfiles
    expose:
      - "8000"

  caddy:
    image: caddy:2
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile
      - static_files:/srv/static
      - caddy_data:/data
      - caddy_config:/config
    depends_on:
      - web

volumes:
  postgres_data:
  static_files:
  caddy_data:
  caddy_config:

Ajoutez un service worker construit depuis la même image, faisant tourner celery -A myproject worker --loglevel=info, si la sortie de heroku ps à l’étape 1 montrait un dyno worker. Il a besoin du même fichier .env et d’un broker (Redis ou RabbitMQ) comme un autre conteneur, de la même façon que Postgres en est un ici. Seul Caddy publie des ports vers l’extérieur ; web et db restent accessibles uniquement depuis les conteneurs sur le même réseau Compose.

Étape 6 : mapper les config vars de Heroku vers .env

Ouvrez heroku-config.txt de l’étape 1 et traduisez-le, variable par variable, dans le .env du serveur, gardé hors du contrôle de version :

POSTGRES_DB=myproject
POSTGRES_USER=myproject
POSTGRES_PASSWORD=change-this
DJANGO_ALLOWED_HOSTS=myproject.example.com
SECRET_KEY=paste-from-heroku-config-vars

SECRET_KEY est la variable qui demande une attention particulière ici. Le SECRET_KEY de Django signe les sessions et les autres cookies signés ; ce n’est pas une clé de chiffrement pour des données stockées, contrairement au RAILS_MASTER_KEY de Rails. Cette distinction compte pour ce qui se passe si vous vous trompez : régénérer SECRET_KEY au lieu de copier la valeur que Heroku avait déjà dans ses config vars ne rend rien d’illisible dans la base de données, mais invalide silencieusement la session de chaque utilisateur connecté et chaque cookie signé que l’application a émis au moment où la nouvelle valeur prend effet, déconnectant tout le monde d’un coup. Copiez-la exactement depuis heroku-config.txt plutôt que d’en générer une nouvelle, à moins que forcer chaque utilisateur à se reconnecter soit réellement un coût acceptable pour ce basculement. Toute autre variable propre à l’application dans heroku-config.txt (clés d’API, feature flags, tout ce que os.environ.get lit dans votre propre code) appartient aussi à ce fichier ; DATABASE_URL, si l’application l’utilise comme une seule chaîne, reçoit une nouvelle valeur qui pointe vers le conteneur db plutôt que l’hôte Postgres de Heroku.

Étape 7 : migrer la base de données

Avec l’application qui ne tourne pas encore sur le nouveau serveur, rapatriez un dump frais depuis Heroku :

heroku pg:backups:capture -a your-app-name
heroku pg:backups:download -a your-app-name -o latest.dump

Copiez-le vers le nouveau serveur et restaurez-le dans le conteneur Postgres fraîchement créé (vide) :

scp latest.dump root@203.0.113.10:/opt/app/
docker compose up -d db
docker compose exec -T db pg_restore -U myproject -d myproject --no-owner --role=myproject /tmp/latest.dump

--no-owner compte particulièrement ici : le dump de Heroku appartient à un rôle dont le nom n’existe pas sur votre nouveau conteneur Postgres, et sans --no-owner, la restauration échoue à mi-chemin sur chaque objet qu’elle ne peut pas réassigner.

Étape 8 : lancer la stack complète et vérifier

docker compose up -d --build
docker compose ps

Vous devriez voir web (et worker, si présent), db et caddy tous en running. Avant de basculer le DNS, testez contre l’IP brute du droplet avec un en-tête Host pour confirmer que l’application sert bien de vraies pages avec de vraies données :

curl -H "Host: myproject.example.com" http://203.0.113.10

Un 400 Bad Request ici veut presque toujours dire que DJANGO_ALLOWED_HOSTS dans .env ne correspond pas au domaine dans l’en-tête Host ; corrigez-le et redéployez web avant de continuer.

Étape 9 : écrire le Caddyfile et basculer le DNS

myproject.example.com {
	encode gzip

	handle_path /static/* {
		root * /srv/static
		file_server
	}

	reverse_proxy web:8000
}

Les fichiers statiques sous /static/ sont servis directement par Caddy depuis le volume partagé ; tout le reste est redirigé vers gunicorn. Baissez le TTL DNS de votre domaine un jour ou deux avant le basculement s’il est actuellement élevé, puis pointez l’enregistrement A vers l’IP du nouveau droplet. Heroku continue de servir les anciennes données jusqu’à ce que le DNS se propage, ce qui est aussi la vraie fenêtre de risque : toute écriture qui se produit sur Heroku après le dump de l’étape 7 mais avant que le trafic bascule complètement vers le nouveau serveur est une ligne que cette migration n’a pas. Une courte fenêtre de maintenance, ou un dernier dump incrémental pris juste avant le changement DNS, referme cet écart pour tout ce qui ne peut pas tolérer de perdre quelques minutes d’écritures.

Le raccourci : le faire avec Railyard

Coller le nom d’une application Heroku (ou connecter Heroku directement) dans importer depuis une autre plateforme lit les trois mêmes choses que l’étape 1 a rapatriées à la main : config vars, formation (types de processus web et worker avec leurs comptes), et add-ons, en mappant heroku-postgresql vers une base de données Postgres provisionnée automatiquement. Heroku n’expose pas votre dépôt via son API de toute façon, donc vous collez quand même l’URL Git depuis laquelle vous poussez, la seule étape manuelle honnête que Railyard ne peut pas sauter non plus. La migration de base de données des étapes 7 et 8 devient un import unique : pointez-le vers la base de données en direct ou téléversez un dump, et il sauvegarde d’abord ce qui existe, restaure, puis fait tourner les migrations automatiquement, le même comportement pg_restore --no-owner sous le capot sans l’option manuelle à retenir. Voir les imports de base de données pour le flux exact, et déployer Django sur DigitalOcean pour la façon dont Railyard détecte une application Django depuis requirements.txt et branche gunicorn, les migrations et Postgres sans le Dockerfile et le fichier Compose que ce guide écrit à la main.

Le plan Gratuit de Railyard couvre un serveur plus un serveur de base de données et trois applications sans carte requise ; le plan Pro est à 19 $ par mois par équipe, et le plan Équipe à 49 $ par mois par équipe pour les projets plus grands. Voir migrer depuis Heroku pour le portrait complet de ce qui bouge et ce qui ne bouge pas.

Dépannage

  • pg_restore: error: could not execute query ... role "..." does not exist : --no-owner a été oublié dans la commande de restauration de l’étape 7 ; ajoutez-le et les mêmes objets se restaurent correctement sous le rôle de connexion à la place.
  • « Bad Request (400) » de Django : DJANGO_ALLOWED_HOSTS ne correspond pas au domaine que vous visitez. Confirmez-le dans .env, redéployez avec docker compose up -d web, et vérifiez docker compose logs web.
  • Tous les utilisateurs se font déconnecter après le basculement : SECRET_KEY dans .env ne correspond pas à la valeur que les config vars de Heroku avaient, généralement parce qu’elle a été générée fraîche à l’étape 6 au lieu d’être copiée ; chaque session et chaque cookie signé étaient déjà invalidés par ce décalage, et copier la valeur d’origine ne corrige pas rétroactivement les sessions créées après que la mauvaise clé ait pris effet.
  • Les jobs en arrière-plan ne se traitent jamais : confirmez que le service worker est bien présent dans docker compose ps et que l’URL de son broker correspond à celle du service web ; un job mis en file contre un broker et un worker qui en interroge un autre ne se rencontrent jamais.
  • HTTPS ne s’obtient jamais après le basculement DNS : la propagation DNS peut prendre plus longtemps que prévu selon le TTL précédent ; dig +short myproject.example.com devrait correspondre à l’IP du nouveau droplet de façon cohérente depuis plusieurs emplacements avant de blâmer Caddy.
  • Le port 80 ou 443 est déjà utilisé : un autre serveur web, souvent un nginx installé par une image one-click de DigitalOcean, y est déjà lié. Arrêtez-le et désactivez-le : systemctl stop nginx && systemctl disable nginx.
  • Les données semblent périmées après le basculement : c’est l’écart d’écriture de l’étape 9 ; si ça compte pour cette application, refaites le dump de l’étape 7 et la restauration de l’étape 8 juste avant de basculer le DNS, avec les écritures mises en pause sur Heroku entre les deux.

FAQ

Est-ce que je peux faire ça sans temps d’arrêt ? Pas avec un simple dump-et-restauration comme ce guide le fait ; une configuration de réplication logique depuis Heroku Postgres vers le nouveau droplet élimine complètement l’écart d’écriture, mais c’est nettement plus de configuration que ce dont la plupart des petites applications ont besoin pour les quelques minutes de risque qu’une fenêtre de maintenance couvre.

Et le système de fichiers éphémère de Heroku, est-ce que quelque chose en dépend ? Si l’application écrit les téléversements des utilisateurs directement sur le disque local en s’attendant à ce qu’ils persistent, c’était déjà cassé sur Heroku entre les redémarrages de dynos ; cette migration est un bon moment pour déplacer le stockage des médias vers un stockage objet ou un volume Docker si ce n’était pas déjà fait.

Est-ce que je dois aussi migrer les jobs planifiés ? Les entrées de Heroku Scheduler ne sont pas couvertes par heroku config ou heroku ps ; vérifiez le tableau de bord de l’add-on Heroku Scheduler séparément et recréez-les comme des entrées cron ou un planning cron dédié de Railyard, puisque rien dans les étapes 1 à 9 ci-dessus ne les fait apparaître automatiquement.

Est-ce qu’un droplet de 2 Go est vraiment comparable à un dyno Heroku ? Le CPU et la mémoire réels d’un dyno standard-1x ou standard-2x sont modestes en comparaison ; un droplet de 2 Go fait généralement tourner Postgres, gunicorn et Caddy ensemble pour moins que le coût équivalent d’un dyno plus l’add-on heroku-postgresql, même si le vrai test est l’empreinte mémoire de votre propre application sous un trafic réel, pas une fiche technique.

Que faire si la migration tourne mal après que le DNS ait déjà basculé ? Gardez l’application Heroku en marche (pas mise à l’échelle zéro) pendant au moins quelques jours après le basculement, précisément pour que le DNS puisse être repointé vers elle immédiatement si quelque chose ne va pas sur le nouveau droplet ; c’est le vrai plan de retour en arrière, pas un fichier de sauvegarde.

Une application Django qui coûtait un dyno Heroku et un add-on heroku-postgresql chaque mois tourne maintenant sur un droplet DigitalOcean à 12 $, avec les mêmes données Postgres, les mêmes sessions signées, et les mêmes jobs en arrière-plan, simplement sur une infrastructure que vous avez provisionnée et que vous comprenez pleinement.

Pourquoi les gens choisissent Railyard pour ça

  • Votre propre compte infonuagique. Déployez sur DigitalOcean, Hetzner, Vultr, AWS, OVHcloud, ou n'importe quel serveur Linux par SSH, facturé directement par votre fournisseur, sans majoration.
  • Un prix fixe par équipe. L'offre gratuite est gratuite pour toujours : un serveur plus un serveur de base de données, trois applications, sans carte. Pro coûte 19 $ par mois pour jusqu'à cinq serveurs et un nombre illimité d'applications. Team coûte 49 $ par mois avec des serveurs illimités. Aucuns frais par application, par déploiement, par siège ou par gigaoctet.
  • Des gabarits en un clic. Déployez des applications open source, comme des bases de données, des outils d'analyse et des outils de développement, sur votre propre serveur en un clic.
  • Des bases de données gérées. Postgres, MySQL et Redis, avec des sauvegardes planifiées, vérifiées par restauration, et la récupération à un point dans le temps.
  • Déploiement par push. Déployez depuis GitHub, GitLab ou la CLI, avec HTTPS automatique et des mises en production sans interruption.

Sautez la migration manuelle.

Collez le nom de votre application Heroku dans Railyard et il lit vos config vars, votre formation et vos add-ons, puis importe la base de données directement.

Offre gratuite, sans carte, et 14 jours de Pro pour chaque nouvelle équipe. Prix fixe par équipe.