Migrar una aplicación Django de Heroku a DigitalOcean
Sigue esto en tu propio servidor. Empieza gratis, sin tarjeta
Los dynos, las config vars y heroku pg:backups de Heroku esconden una cantidad real de trabajo de infraestructura detrás de una CLI, y es exactamente por eso que irse es más complicado que desplegar en otro lugar. Una aplicación Django en Heroku necesita un servidor que se mantenga activo, un gestor de procesos que ejecute gunicorn, un worker de Celery si la aplicación tiene uno, una base de datos Postgres con los datos reales dentro, cada config var mapeada a un entorno real, y un dominio que se cambie sin un hueco donde ninguno de los dos servidores tenga los datos actuales. Esta guía hace todo eso contra un droplet de DigitalOcean, en Docker, terminando con la misma aplicación corriendo en infraestructura que tú controlas.
Lo que necesitas
- Un droplet de DigitalOcean. El plan Basic de 1 vCPU / 2 GB de RAM ejecuta Postgres, gunicorn y Caddy juntos sin problemas y cuesta actualmente $12/mes; el plan de 1 GB ($6/mes) queda demasiado justo una vez que Postgres también está corriendo.
- La CLI de Heroku, ya autenticada, para extraer las config vars y un dump de la base de datos.
- Un dominio actualmente apuntado a Heroku, con acceso para cambiar su DNS.
- El código fuente de la aplicación en un repositorio Git, con un
requirements.txtque Heroku ya usa para construir. Esta guía usa Django 6.1.2, gunicorn 26.2.0 como servidor WSGI, y psycopg2-binary 2.9.13 para hablar con Postgres. - Docker y el plugin Compose, ya sea localmente para probar primero el build, o en el servidor.
Paso 1: hacer un inventario de lo que realmente corre en Heroku
La API de Heroku no expone tu repositorio directamente, así que este paso es manual, y saltárselo es la forma más fácil de olvidar algo en el nuevo servidor. Extrae las tres cosas que necesitas:
heroku config -a your-app-name > heroku-config.txt
heroku ps -a your-app-name
heroku addons -a your-app-name
heroku ps muestra tu formation actual. Para una aplicación Django, normalmente es un dyno web ejecutando gunicorn, y tal vez un dyno worker ejecutando Celery si la aplicación procesa trabajos en segundo plano. heroku addons muestra lo que realmente está aprovisionado, casi siempre heroku-postgresql en algún plan, que necesita un contenedor Postgres equivalente en el nuevo servidor.
Paso 2: crear el droplet
Desde el panel de DigitalOcean, crea un nuevo droplet: elige la imagen Ubuntu 24.04 LTS, el plan Basic de 2 GB, una región cercana a tus usuarios, y tu llave SSH. Una vez que esté corriendo, anota su dirección IP pública y apunta el registro A de tu dominio hacia ella:
myproject.example.com. A 203.0.113.10
La propagación del DNS puede tardar desde unos minutos hasta unas horas; puedes seguir configurando el servidor mientras tanto. Conéctate por SSH como root e instala Docker:
ssh root@203.0.113.10
curl -fsSL https://get.docker.com | sh
apt-get install -y docker-compose-plugin
Verifica que haya funcionado:
docker compose version
Paso 3: preparar la aplicación Django para producción
El Procfile de Heroku y sus dynos efímeros esconden algunos ajustes que Django necesita tener explícitos en un servidor que administras tú mismo. En 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 la aplicación actualmente lee DATABASE_URL como una sola cadena a través de django-environ o dj-database-url en Heroku, eso sigue funcionando; las variables de entorno de arriba son solo la versión más simple una vez que la base de datos es un contenedor llamado db en lugar del host de Postgres propio de Heroku. Confirma que las versiones exactas fijadas en requirements.txt coincidan con lo que esta guía asume:
django==6.1.2
gunicorn==26.2.0
psycopg2-binary==2.9.13
Paso 4: escribir el 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"]
Reemplaza myproject.wsgi por el paquete real de tu proyecto. Ejecutar migrate antes de que arranque gunicorn significa que cada despliegue aplica las migraciones pendientes automáticamente, lo cual reemplaza el paso de release que Heroku ejecutaba (una línea release: python manage.py migrate en el Procfile, muy probablemente). Tres workers de gunicorn son un buen punto de partida para un droplet de 2 GB; vigila la memoria bajo carga y ajusta.
Paso 5: escribir 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:
Agrega un servicio worker construido desde la misma imagen, ejecutando celery -A myproject worker --loglevel=info, si la salida de heroku ps del paso 1 mostraba un dyno worker. Necesita el mismo archivo .env y un broker (Redis o RabbitMQ) como otro contenedor, de la misma forma en que Postgres es uno aquí. Solo Caddy publica puertos hacia el exterior; web y db permanecen accesibles únicamente desde contenedores en la misma red de Compose.
Paso 6: mapear las config vars de Heroku a .env
Abre heroku-config.txt del paso 1 y tradúcelo, variable por variable, al .env del servidor, mantenido fuera del control de versiones:
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 es la variable que necesita un cuidado especial aquí. El SECRET_KEY de Django firma las sesiones y otras cookies firmadas; no es una clave de cifrado para datos almacenados, a diferencia del RAILS_MASTER_KEY de Rails. Esa distinción importa para lo que pasa si te equivocas: regenerar SECRET_KEY en lugar de copiar el valor que Heroku ya tenía en sus config vars no vuelve ilegible nada en la base de datos, pero invalida silenciosamente la sesión de cada usuario conectado y cada cookie firmada que la aplicación emitió en el momento en que el nuevo valor entra en vigor, cerrando la sesión de todos a la vez. Cópialo exactamente desde heroku-config.txt en lugar de generar uno nuevo, a menos que forzar a cada usuario a iniciar sesión de nuevo sea realmente un costo aceptable para este cambio. Cualquier otra variable específica de la aplicación en heroku-config.txt (claves de API, feature flags, cualquier cosa que os.environ.get lea en tu propio código) también pertenece a este archivo; DATABASE_URL, si la aplicación la usa como una sola cadena, recibe un nuevo valor que apunta al contenedor db en lugar del host de Postgres propio de Heroku.
Paso 7: migrar la base de datos
Con la aplicación aún sin correr en el nuevo servidor, extrae un dump fresco desde Heroku:
heroku pg:backups:capture -a your-app-name
heroku pg:backups:download -a your-app-name -o latest.dump
Cópialo al nuevo servidor y restáuralo en el contenedor Postgres recién creado (vacío):
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 importa específicamente aquí: el dump de Heroku pertenece a un rol cuyo nombre no existe en tu nuevo contenedor Postgres, y sin --no-owner, la restauración falla a medio camino en cada objeto que no puede reasignar.
Paso 8: levantar toda la stack y verificar
docker compose up -d --build
docker compose ps
Deberías ver web (y worker, si está presente), db y caddy todos en running. Antes de cambiar el DNS, prueba contra la IP cruda del droplet con un header Host para confirmar que la aplicación realmente sirve páginas reales con datos reales:
curl -H "Host: myproject.example.com" http://203.0.113.10
Un 400 Bad Request aquí casi siempre significa que DJANGO_ALLOWED_HOSTS en .env no coincide con el dominio en el header Host; corrígelo y vuelve a desplegar web antes de continuar.
Paso 9: escribir el Caddyfile y cambiar el DNS
myproject.example.com {
encode gzip
handle_path /static/* {
root * /srv/static
file_server
}
reverse_proxy web:8000
}
Los archivos estáticos bajo /static/ son servidos directamente por Caddy desde el volumen compartido; todo lo demás se enruta a gunicorn. Baja el TTL del DNS de tu dominio uno o dos días antes del cambio si actualmente es alto, y luego apunta el registro A a la IP del nuevo droplet. Heroku sigue sirviendo los datos antiguos hasta que el DNS se propague, lo cual es también la ventana de riesgo real: cualquier escritura que ocurra en Heroku después del dump del paso 7 pero antes de que el tráfico se mueva por completo al nuevo servidor es una fila que esta migración no tiene. Una ventana de mantenimiento corta, o un dump incremental final tomado justo antes del cambio de DNS, cierra ese hueco para cualquier cosa que no pueda tolerar perder unos minutos de escrituras.
El atajo: hacerlo con Railyard
Pegar el nombre de una aplicación de Heroku (o conectar Heroku directamente) en importar desde otra plataforma lee las mismas tres cosas que el paso 1 extrajo a mano: config vars, formation (tipos de proceso web y worker con sus cantidades), y add-ons, mapeando heroku-postgresql a una base de datos Postgres aprovisionada automáticamente. De todas formas Heroku no expone tu repositorio a través de su API, así que igual pegas la URL de Git desde la que haces push, el único paso manual honesto que Railyard tampoco puede saltarse. La migración de la base de datos de los pasos 7 y 8 se convierte en una sola importación: apúntala a la base de datos en vivo o sube un dump, y hace una copia de seguridad de lo que ya existe primero, restaura, y luego corre las migraciones automáticamente, el mismo comportamiento pg_restore --no-owner por debajo sin la bandera manual que recordar. Mira importaciones de bases de datos para el flujo exacto, y desplegar Django en DigitalOcean para cómo Railyard detecta una aplicación Django desde requirements.txt y conecta gunicorn, las migraciones y Postgres sin el Dockerfile ni el archivo Compose que esta guía escribe a mano.
El plan Free de Railyard cubre un servidor más un servidor de base de datos y tres aplicaciones sin necesidad de tarjeta; el plan Pro cuesta $19/mes por equipo, y el plan Team cuesta $49/mes por equipo para proyectos más grandes. Mira migrar desde Heroku para el panorama completo de qué se mueve y qué no.
Solución de problemas
pg_restore: error: could not execute query ... role "..." does not exist: se omitió--no-owneren el comando de restauración del paso 7; agrégalo y los mismos objetos se restauran correctamente bajo el rol de conexión en su lugar.- “Bad Request (400)” de Django:
DJANGO_ALLOWED_HOSTSno coincide con el dominio que estás visitando. Confírmalo en.env, vuelve a desplegar condocker compose up -d web, y revisadocker compose logs web. - Todos los usuarios quedan desconectados después del cambio:
SECRET_KEYen.envno coincide con el valor que tenían las config vars de Heroku, usualmente porque se generó uno nuevo en el paso 6 en lugar de copiarlo; cada sesión y cada cookie firmada ya habían quedado invalidadas por ese desajuste, y copiar el valor original no corrige retroactivamente las sesiones creadas después de que la clave incorrecta entrara en vigor. - Los trabajos en segundo plano nunca se procesan: confirma que el servicio
workerrealmente esté presente endocker compose psy que la URL de su broker coincida con la del servicioweb; un trabajo encolado contra un broker y un worker consultando otro nunca se encuentran. - HTTPS nunca se emite después del cambio de DNS: la propagación del DNS puede tardar más de lo esperado según el TTL anterior;
dig +short myproject.example.comdebería coincidir con la IP del nuevo droplet de forma consistente desde varias ubicaciones antes de asumir que Caddy tiene la culpa. - El puerto 80 o 443 ya está en uso: otro servidor web, a menudo un nginx instalado por una imagen one-click de DigitalOcean, ya está vinculado a él. Detenlo y desactívalo:
systemctl stop nginx && systemctl disable nginx. - Los datos se ven desactualizados después del cambio: este es el hueco de escritura del paso 9; si importa para esta aplicación, repite el dump del paso 7 y la restauración del paso 8 justo antes de cambiar el DNS, con las escrituras pausadas en Heroku mientras tanto.
Preguntas frecuentes
¿Puedo hacer esto sin tiempo de inactividad? No con un simple dump y restauración como lo hace esta guía; una configuración de replicación lógica desde Heroku Postgres hacia el nuevo droplet elimina por completo el hueco de escritura, pero es considerablemente más configuración de la que la mayoría de las aplicaciones pequeñas necesitan para los pocos minutos de riesgo que cubre una ventana de mantenimiento.
¿Y el sistema de archivos efímero de Heroku, depende algo de él? Si la aplicación escribe los archivos subidos por los usuarios directamente en disco local esperando que persistan, eso ya estaba roto en Heroku entre reinicios de dynos; esta migración es un buen momento para mover el almacenamiento de medios a un object store o a un volumen de Docker si no se había hecho ya.
¿También tengo que migrar los trabajos programados? Las entradas de Heroku Scheduler no están cubiertas por heroku config ni heroku ps; revisa el panel del add-on Heroku Scheduler por separado y recréalas como entradas de cron o un horario de cron dedicado de Railyard, ya que nada en los pasos 1 al 9 de arriba las muestra automáticamente.
¿Un droplet de 2 GB es realmente comparable a un dyno de Heroku? El CPU y la memoria reales de un dyno standard-1x o standard-2x son modestos en comparación; un droplet de 2 GB generalmente ejecuta Postgres, gunicorn y Caddy juntos por menos que el costo equivalente de un dyno más el add-on heroku-postgresql, aunque la prueba real es la huella de memoria de tu propia aplicación bajo tráfico real, no una hoja de especificaciones.
¿Qué pasa si la migración sale mal después de que el DNS ya cambió? Mantén la aplicación de Heroku corriendo (no escalada a cero) durante al menos unos días después del cambio, precisamente para que el DNS pueda apuntarse de vuelta a ella de inmediato si algo anda mal en el nuevo droplet; ese es el verdadero plan de reversión, no un archivo de respaldo.
Una aplicación Django que costaba un dyno de Heroku y un add-on heroku-postgresql cada mes ahora corre en un droplet de DigitalOcean a $12, con los mismos datos de Postgres, las mismas sesiones firmadas, y los mismos trabajos en segundo plano, solo que en infraestructura que tú aprovisionaste y que entiendes por completo.
Por qué eligen Railyard para esto
- Tu propia cuenta en la nube. Despliega en DigitalOcean, Hetzner, Vultr, AWS, OVHcloud, o cualquier servidor Linux por SSH, facturado directamente por tu proveedor, sin márgenes adicionales.
- Un precio fijo por equipo. El plan Free es gratis para siempre: un servidor más un servidor de base de datos, tres apps, sin tarjeta. Pro cuesta $19 al mes por hasta cinco servidores y apps ilimitadas. Team cuesta $49 al mes con servidores ilimitados. Sin cargos por app, por despliegue, por asiento ni por gigabyte.
- Plantillas de un clic. Despliega apps de código abierto, como bases de datos, analítica y herramientas de desarrollo, en tu propio servidor con un clic.
- Bases de datos administradas. Postgres, MySQL y Redis, con copias de seguridad programadas y verificadas por restauración, y recuperación a un punto en el tiempo.
- Despliegue con git push. Despliega desde GitHub, GitLab o la CLI, con HTTPS automático y lanzamientos sin tiempo de inactividad.