Desplegar Django con Celery en Hetzner
Sigue esto en tu propio servidor. Empieza gratis, sin tarjeta
Una app de Django con tareas en segundo plano no es un solo proceso, son al menos tres: gunicorn sirviendo las peticiones, un worker de Celery vaciando la cola, y Redis guardando esa cola entre los dos. La mayoría de los tutoriales se detienen en runserver y dejan el worker, el broker y el certificado HTTPS como ejercicio para después. Esta guía construye todo en un servidor Hetzner: gunicorn detrás de Caddy, un worker de Celery y un programador beat opcional, Postgres y Redis, todo como servicios de Docker Compose con respaldos reales y un proceso de actualización real. El atajo al final despliega la misma app con un simple git push.
Esta guía usa Postgres 16 y Redis 8.
Qué necesitas
- Un servidor de Hetzner Cloud. Un CPX31 (4 vCPU, 8 GB) corre gunicorn, un worker de Celery, Postgres y Redis juntos sin hacer swap, por 62,99 € al mes sin IVA. Un CX23 más chico (2 vCPU, 4 GB) a 5,99 € al mes sin IVA funciona para una app de bajo tráfico con una cola liviana, solo que queda más justo una vez que los buffers compartidos de Postgres y los procesos worker de Celery están los dos en memoria.
- Un nombre de dominio, apuntado a la IP del servidor antes del paso de HTTPS.
- Una cuenta de Hetzner Cloud y un token de API (Security → API Tokens, Read & Write) si quieres automatizar la creación del servidor; la consola de Hetzner Cloud funciona igual de bien si prefieres hacerlo con clics una sola vez.
Paso 1: crear el servidor
Desde la consola de Hetzner Cloud, crea un servidor con Ubuntu 24.04, el tamaño elegido, y una región cercana a tus usuarios. Si prefieres automatizarlo:
curl -X POST "https://api.hetzner.cloud/v1/servers" \
-H "Authorization: Bearer $HETZNER_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "django-app",
"server_type": "cpx31",
"image": "ubuntu-24.04",
"location": "nbg1",
"ssh_keys": ["your-ssh-key-name"]
}'
Deberías recibir una respuesta JSON con un server.id y una server.public_net.ipv4.ip. Conéctate por SSH con esa IP una vez que termine de arrancar, normalmente en menos de un minuto:
ssh root@YOUR_SERVER_IP
Paso 2: instalar Docker y apuntar el DNS
curl -fsSL https://get.docker.com | sh
docker compose version
Deberías ver una cadena de versión de Compose v2.x. Mientras eso se instala, crea el registro DNS A de tu dominio y confirma que resuelve:
dig +short app.example.com
Necesita devolver la IP del servidor antes de que Caddy pueda emitir un certificado más adelante, así que haz este paso temprano y déjalo propagarse en segundo plano.
Paso 3: escribir la imagen de Django
Django no trae una imagen oficial como Postgres o Redis, ya que las dependencias de cada proyecto difieren, así que construyes la tuya:
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq-dev gcc \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN python manage.py collectstatic --noinput
CMD ["gunicorn", "myproject.wsgi:application", "--bind", "0.0.0.0:8000"]
libpq-dev y gcc están ahí para psycopg2 (la versión compilada, no binaria); quítalos si tu requirements.txt fija psycopg2-binary en su lugar, que trae su propio libpq ya compilado. Correr collectstatic en tiempo de build hace que un archivo estático roto nunca llegue al tráfico de producción, ya que el build falla antes de que la imagen se suba.
Paso 4: escribir el archivo de Docker Compose
Cinco servicios: la app de Django como web, la misma imagen otra vez como worker y beat con comandos distintos, Postgres, Redis, y Caddy al frente.
services:
web:
build: .
restart: unless-stopped
command: gunicorn myproject.wsgi:application --bind 0.0.0.0:8000 --workers 3
env_file: .env
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
networks:
- app_net
worker:
build: .
restart: unless-stopped
command: celery -A myproject worker --loglevel=info --concurrency=2
env_file: .env
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
networks:
- app_net
beat:
build: .
restart: unless-stopped
command: celery -A myproject beat --loglevel=info
env_file: .env
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
networks:
- app_net
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: myproject
POSTGRES_USER: myproject
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U myproject"]
interval: 5s
start_period: 10s
retries: 20
networks:
- app_net
redis:
image: redis:8-alpine
restart: unless-stopped
volumes:
- redis_data:/data
networks:
- app_net
caddy:
image: caddy:2
restart: unless-stopped
depends_on:
- web
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
- caddy_config:/config
networks:
- app_net
networks:
app_net:
volumes:
postgres_data:
redis_data:
caddy_data:
caddy_config:
Quita por completo el servicio beat si no programas tareas periódicas. worker y beat se construyen desde exactamente el mismo Dockerfile que web, ese es el punto del modelo de procesos de 12 factores: un solo código base, tres roles, iniciados con tres comandos distintos en lugar de tres repos que mantener sincronizados.
Escribe el .env al lado:
POSTGRES_PASSWORD=una-cadena-larga-aleatoria
DJANGO_SETTINGS_MODULE=myproject.settings
SECRET_KEY=otra-cadena-larga-aleatoria
DEBUG=False
ALLOWED_HOSTS=app.example.com
DATABASE_URL=postgres://myproject:una-cadena-larga-aleatoria@db:5432/myproject
CELERY_BROKER_URL=redis://redis:6379/0
CELERY_RESULT_BACKEND=redis://redis:6379/0
SECRET_KEY y la contraseña de Postgres deberían venir cada una de openssl rand -hex 32, no de algo escrito a mano.
Paso 5: correr las migraciones y levantar la pila
docker compose up -d --build
docker compose run --rm web python manage.py migrate
Deberías ver la salida de las migraciones de Django terminando con una lista de migraciones aplicadas, no un error de tabla faltante o conexión rechazada a db. Después revisa que el worker haya tomado el broker:
docker compose logs worker --tail=20
Deberías ver una línea como celery@worker ready. confirmando que se conectó a Redis y está esperando tareas.
Paso 6: escribir el Caddyfile
app.example.com {
reverse_proxy web:8000
handle_path /static/* {
root * /srv/static
file_server
}
}
Caddy solicita y renueva su propio certificado vía Let’s Encrypt la primera vez que puede responder en el puerto 80 para un dominio que ya resuelve a este servidor, sin un paso separado de certbot. Si sirves archivos estáticos directamente desde Caddy en lugar de WhiteNoise, monta el mismo directorio estático que el contenedor web escribe en el servicio caddy como /srv/static.
Paso 7: respaldos
Postgres tiene los datos que de verdad importan; respáldalos con pg_dump en un horario de cron en lugar de confiar solo en el volumen:
#!/usr/bin/env bash
set -euo pipefail
STAMP=$(date +%Y%m%d-%H%M%S)
mkdir -p backups
docker compose exec -T db pg_dump -U myproject myproject | gzip > "backups/django-db-$STAMP.sql.gz"
find backups -name '*.sql.gz' -mtime +14 -delete
Copia backups/ fuera del servidor en el mismo horario, a almacenamiento de objetos u otra máquina. Un dump que nunca sale del servidor donde se tomó no protege contra una falla del servidor mismo.
Paso 8: actualizaciones
Construye tu propia imagen nueva, aplica las migraciones, y luego recrea los contenedores de la app de uno en uno:
docker compose build
docker compose run --rm web python manage.py migrate
docker compose up -d web worker beat
Correr la migración como un run --rm aparte antes de tocar los contenedores de larga duración hace que una migración fallida detenga el despliegue en lugar de reiniciar parcialmente web contra un esquema que no espera.
El atajo: hazlo con Railyard
El inspector de repositorios de Railyard lee requirements.txt y manage.py para detectar Django por su cuenta, sin necesidad de Dockerfile ni Procfile. Fija el comando de release en python manage.py migrate --no-input, arranca web con gunicorn atado al puerto asignado, y corre collectstatic automáticamente en el paso de build. Una dependencia psycopg/psycopg2 provisiona Postgres e inyecta DATABASE_URL; un cliente Redis (redis, django-redis, rq) hace lo mismo para REDIS_URL. Si Celery está en tus dependencias, se ofrece como proceso worker por su cuenta, y Celery Beat se agrega también si django-celery-beat está presente o tus settings definen un horario de beat, así que no hay un Procfile que escribir a mano para ninguno de los dos.
railyard create my-app https://github.com/me/my-app --addons postgres,redis
o pega la misma URL de Git en el dashboard una vez conectado GitHub. Escala el worker de forma independiente una vez que está corriendo:
railyard scale my-app worker=2
Hetzner es uno de los proveedores en los que Railyard puede crear un servidor directamente desde tu propia cuenta, facturado al precio de Hetzner sin recargo; mira Agregar un servidor. Referencia completa del framework en Django, y Desplegar desde un repositorio para todo lo demás que se lee del repo. Mira la página práctica de Django en Hetzner para detalles de tamaño y región, y Bases de datos y respaldos para ver cómo funcionan los respaldos programados de Postgres y la restauración a un punto en el tiempo una vez conectada.
Solución de problemas
El puerto 80 o 443 ya está en uso. Una instalación por defecto de servidor web muchas veces los toma primero. sudo ss -tlnp | grep -E ':80|:443' te muestra el proceso; detenlo o reconfigúralo antes de que Caddy arranque.
ALLOWED_HOSTS rechaza cada petición. Django devuelve un 400 con un mensaje claro nombrando el host que esperaba, en los logs que puedes leer con docker compose logs web. Agrega el dominio exacto (y la variante www. si sirves ambos) a ALLOWED_HOSTS en .env, y reinicia el servicio web.
El worker de Celery arranca pero nunca corre nada. Casi siempre es CELERY_BROKER_URL apuntando a algo que el worker no puede alcanzar, comúnmente localhost que quedó del desarrollo local en lugar del nombre del servicio de Compose redis. Confírmalo con docker compose exec worker env | grep CELERY.
El certificado nunca se emite. Revisa primero que dig +short app.example.com resuelva al servidor; Caddy no puede completar un desafío HTTP-01 para un dominio que todavía no apunta aquí. Revisa también que tu firewall de nube permita los puertos 80 y 443 entrantes, no solo los que estás probando activamente.
Sin memoria en un servidor pequeño. Postgres, Redis, gunicorn y un worker de Celery suman rápido en una máquina de 4 GB si el número de workers de gunicorn queda demasiado alto. --workers 3 aquí asume una máquina de 4 vCPU; redúcelo a la mitad en un CX23, o mueve Postgres a su propio servidor una vez que el tráfico realmente lo justifique.
Preguntas frecuentes
¿Necesito tanto un worker como un proceso beat? Solo beat, si programas tareas periódicas con @shared_task y un CELERYBEAT_SCHEDULE o django-celery-beat. Una app que solo despacha tareas bajo demanda (desde una vista, digamos) solo necesita el worker.
¿Puedo correr Celery y Django en el mismo contenedor? Puedes arrancar ambos procesos en un contenedor con un supervisor de procesos, pero una falla en uno entonces tumba al otro, y pierdes la capacidad de escalar el worker de forma independiente del tráfico web. Contenedores separados desde la misma imagen, como arriba, evitan los dos problemas.
¿Por qué Caddy en lugar de nginx? Caddy solicita y renueva certificados de Let’s Encrypt por su cuenta desde una config de seis líneas; nginx necesita certbot conectado aparte y una tarea cron para renovar. Los dos funcionan, Caddy solo requiere menos mantenimiento.
¿Y si mi app necesita una versión específica de Python? Cambia la línea FROM python:3.12-slim según corresponda; Django y Celery soportan las versiones de Python actualmente mantenidas, revisa las restricciones de tus propias dependencias si alguna fija una versión más antigua.
Los dos caminos terminan con los mismos tres procesos hablando con el mismo Postgres y el mismo Redis, el archivo Compose si quieres ver cada línea, el push si prefieres volver al código real de Django.
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.