← Blog

Desplegar Django en DigitalOcean

Railyard

1 de mayo de 2026 · 8 min de lectura

Sigue esto en tu propio servidor. Empieza gratis, sin tarjeta

Tienes una app Django que funciona en tu computadora, y una cuenta de DigitalOcean. Lo que falta es todo lo del medio: un servidor que se mantenga en pie, una base de datos Postgres en lugar de SQLite, un gestor de procesos para que gunicorn se reinicie si falla, archivos estáticos servidos correctamente, y un certificado para que el sitio responda en https:// en vez de en una simple dirección IP. Nada de esto es difícil por separado, pero es fácil dejar una pieza casi correcta y pasarte una noche entera con ALLOWED_HOSTS.

Esta guía arma todo con Docker Compose: un contenedor para Postgres, uno para tu app Django detrás de gunicorn, y uno para Caddy encargándose del HTTPS y los archivos estáticos. Al final tendrás un droplet sirviendo tu app en tu propio dominio, con una forma repetible de desplegar nuevas versiones y respaldar la base de datos. Después vemos el atajo: subir el mismo repositorio a Railyard y dejar que lea requirements.txt por ti.

Lo que necesitas

  • Un droplet de DigitalOcean. El plan Basic de 1 vCPU / 2 GB de RAM corre Postgres, gunicorn y Caddy juntos sin problema y cuesta actualmente $12 USD/mes; el plan de 1 GB ($6 USD/mes) sirve para apps muy pequeñas pero deja poco margen una vez que Postgres también está corriendo.
  • Un nombre de dominio, con acceso para agregar un registro A.
  • Un repositorio en GitHub (o cualquier otro host Git) con tu proyecto Django.
  • Una cuenta de DigitalOcean con la facturación configurada, y una llave SSH agregada a tu cuenta.

Paso 1: crear el droplet

Desde el panel de DigitalOcean, crea un droplet nuevo: 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 de DNS puede tardar desde minutos hasta algunas horas. Puedes seguir configurando el servidor mientras tanto; Caddy solo necesita que el registro DNS sea correcto cuando intente emitir un certificado.

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

Comprueba que funcionó:

docker compose version

Deberías ver una cadena de versión del plugin de Compose, no un error de “command not found”.

Paso 2: preparar la app Django para producción

Los valores por defecto de Django asumen un desarrollador corriendo manage.py runserver en local, no un servidor. Algunos ajustes deben cambiar para producción. 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"

Fija las versiones de los paquetes que necesitas en requirements.txt. Esta guía usa Django 6.1, gunicorn 26.2 como servidor WSGI, y psycopg2-binary 2.9.13 para hablar con Postgres:

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

Si ya usas django-environ o dj-database-url para leer DATABASE_URL como una sola cadena, eso también funciona; las variables de entorno de arriba son solo la versión más simple.

Paso 3: 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 nombre real de tu paquete de proyecto. Ejecutar migrate antes de que arranque gunicorn hace que cada despliegue aplique las migraciones pendientes automáticamente; para una configuración de un solo servidor como esta, eso es más simple que un paso de release separado. Tres workers de gunicorn es un buen punto de partida para un droplet de 2 GB; vigila la memoria bajo carga y ajusta.

Paso 4: escribir docker-compose.yml

services:
  db:
    image: postgres:18
    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:

Solo Caddy publica puertos hacia afuera; web y db quedan accesibles únicamente desde los contenedores de la misma red de Compose, una cosa menos expuesta a internet por accidente.

Paso 5: escribir el Caddyfile

myproject.example.com {
	encode gzip

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

	reverse_proxy web:8000
}

Caddy solicita y renueva automáticamente un certificado Let’s Encrypt para myproject.example.com la primera vez que arranca, siempre que el DNS del dominio ya apunte a este servidor y los puertos 80 y 443 lo alcancen. Los archivos estáticos bajo /static/ los sirve Caddy directamente desde el volumen compartido; todo lo demás se reenvía a gunicorn.

Paso 6: configurar tus variables de entorno y secretos

Crea un archivo .env junto a docker-compose.yml, y mantenlo fuera del control de versiones:

POSTGRES_DB=myproject
POSTGRES_USER=myproject
POSTGRES_PASSWORD=una-contrasena-larga-y-aleatoria
DJANGO_ALLOWED_HOSTS=myproject.example.com
SECRET_KEY=otro-valor-largo-y-aleatorio

Genera ambos valores aleatorios con openssl rand -base64 32 en lugar de escribir algo memorable; esta base de datos y esta instancia de Django solo necesitan ser imposibles de adivinar, no fáciles de recordar.

Paso 7: abrir el firewall y desplegar

ufw allow 22
ufw allow 80
ufw allow 443
ufw enable

Luego, desde el directorio con tu docker-compose.yml:

docker compose up -d --build
docker compose logs -f caddy

Deberías ver que Caddy registra que obtuvo un certificado para tu dominio, y luego docker compose logs -f web debería mostrar a gunicorn arrancando y las migraciones aplicándose. Visita https://myproject.example.com en un navegador: deberías ver tu app en HTTPS con un certificado válido, no una advertencia del navegador.

Respaldos y actualizaciones

Respalda Postgres con un dump programado, escrito fuera del contenedor para que un volumen perdido no se lleve el respaldo con él:

docker compose exec -T db pg_dump -U myproject myproject > backup-$(date +%F).sql

Pon eso en una tarea cron y copia el resultado fuera del droplet (DigitalOcean Spaces, S3, o simplemente scp a otra máquina). Para actualizar la app, trae tu código más reciente, reconstruye, y vuelve a levantar los contenedores; el paso migrate dentro del CMD del Dockerfile se ejecuta de nuevo automáticamente:

git pull
docker compose up -d --build web

El atajo: hacerlo con Railyard

Todo lo anterior (el Dockerfile, gunicorn, el paso de migración, DATABASE_URL, el proxy Caddy y el certificado) es lo que Railyard configura automáticamente cuando lo apuntas al mismo repositorio. Según sus propias reglas de detección: Django se reconoce a partir de requirements.txt, pyproject.toml, o un manage.py que importa Django; el paso de release ejecuta python manage.py migrate --no-input antes de que la nueva versión reciba tráfico; el proceso web corre sobre gunicorn, enlazado al puerto que Railyard asigna; y una dependencia psycopg/psycopg2 provisiona automáticamente una base de datos Postgres, con DATABASE_URL inyectado como variable de entorno, sin necesidad de Dockerfile ni archivo Compose. collectstatic también se ejecuta automáticamente durante el build, así que WhiteNoise o una configuración estándar de STATIC_ROOT funciona sin que tengas que escribir ese paso tú mismo. Mira la documentación de Django para las reglas exactas, y Bases de datos y respaldos para saber cómo se respalda y restaura la instancia de Postgres administrada.

Para desplegar de esta forma: crea un droplet o servidor desde Railyard (DigitalOcean es uno de los proveedores soportados), luego pega la URL Git de tu repositorio, o agrega esto a tu README para un despliegue de un clic:

[![Deploy to Railyard](https://app.railyard.run/deploy/badge)](https://app.railyard.run/deploy?repo=https://github.com/you/your-django-app)

El camino manual de arriba sigue siendo el que vale la pena conocer. Es lo que Railyard hace por debajo, y entenderlo significa que nunca quedas bloqueado si algún cambio todavía no está expuesto en el panel.

Solución de problemas

Caddy nunca obtiene un certificado. Verifica que el registro A del dominio realmente apunte al droplet (dig myproject.example.com) y que los puertos 80 y 443 estén abiertos tanto en el firewall del droplet como en el firewall de nube de DigitalOcean si agregaste uno. Let’s Encrypt valida primero por HTTP simple, así que el puerto 80 tiene que ser alcanzable aunque el sitio final sea solo HTTPS.

“Bad Request (400)” de Django. Esto casi siempre es ALLOWED_HOSTS sin coincidir con el dominio que estás visitando. Confirma que DJANGO_ALLOWED_HOSTS en .env coincide exactamente, vuelve a desplegar con docker compose up -d web, y revisa docker compose logs web.

Los archivos estáticos dan 404. collectstatic tiene que ejecutarse antes de que Caddy pueda servir algo desde /static/, y el volumen static_files tiene que estar montado tanto en web como en caddy en las rutas que cada uno espera. Si cambiaste STATIC_ROOT, actualiza el Dockerfile y las rutas de volumen en docker-compose.yml.

El contenedor de Postgres no arranca, o los datos parecen borrados tras un reinicio. Revisa docker compose logs db buscando errores de permisos en /var/lib/postgresql/data; esto normalmente significa que el volumen con nombre fue eliminado y recreado (por ejemplo con docker compose down -v, que borra los volúmenes). Nunca uses esa opción a menos que quieras borrar los datos de verdad.

El puerto 80 o 443 ya está en uso. Otro servidor web (a menudo un nginx instalado por una imagen de un clic de DigitalOcean) ya está enlazado a él. Detenlo y desactívalo: systemctl stop nginx && systemctl disable nginx.

Los workers de gunicorn se mueren, o el droplet se siente lento bajo carga. En un droplet de 1 GB, Postgres, tres workers de gunicorn y Caddy pueden competir por memoria. Baja a dos workers, agrega un archivo swap como red de seguridad, o sube a 2 GB si la app tiene tráfico real.

Preguntas frecuentes

¿Necesito Docker, o puedo hacer esto con systemd y un virtualenv? Docker Compose mantiene Postgres, la app y Caddy aislados y fáciles de mover entre servidores, pero un virtualenv con gunicorn corrido por systemd y un Postgres instalado a nivel de sistema funcionan de la misma forma; el comando de gunicorn y la configuración de Caddy de arriba no cambian en ninguno de los dos casos.

¿Puedo usar SQLite en lugar de Postgres? Para un solo droplet con poca escritura, sí, aunque pierdes la posibilidad de correr más de un contenedor web más adelante y los respaldos se vuelven copiar un solo archivo en lugar de un dump. Postgres no cuesta nada extra aquí porque es solo otro contenedor.

¿Y Celery o las tareas en segundo plano? Agrega un servicio worker a docker-compose.yml que corra celery -A myproject worker --loglevel=info sobre la misma imagen y el mismo archivo .env; necesita Redis o RabbitMQ como broker, otro contenedor más, igual que Postgres.

¿Necesito un droplet más grande si agrego más apps después? Solo cuando realmente te quedes sin memoria o CPU. Un mismo droplet puede correr varias apps detrás de la misma instancia de Caddy, en subdominios distintos, cada una con su propio certificado, igual que con una sola.

Una app Django en un droplet no es frágil una vez que las piezas están bien conectadas, y ninguna de estas piezas es específica de Django: el mismo contenedor Postgres, el mismo proxy Caddy y la misma tarea cron de respaldo sirven también para la próxima app que pongas en este servidor.

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.

Evita configurar el droplet

Pega tu repositorio Django en Railyard y gunicorn, las migraciones y Postgres quedan listos por ti.

Plan gratis, sin tarjeta, y 14 días de Pro para cada equipo nuevo. Un precio fijo por equipo.