Deploy Django to DigitalOcean
Follow this on your own server. Start free, no card needed
You have a Django app that works on your laptop, and a DigitalOcean account. What’s missing is everything in between: a server that stays up, a Postgres database instead of SQLite, a process manager so gunicorn restarts when it crashes, static files served correctly, and a certificate so the site answers on https:// instead of a bare IP address. None of that is hard on its own, but it’s easy to get one piece almost right and spend an evening on ALLOWED_HOSTS instead.
This guide builds the whole thing with Docker Compose: one container for Postgres, one for your Django app behind gunicorn, and one for Caddy handling HTTPS and static files. By the end you’ll have a droplet serving your app on your own domain, with a repeatable way to deploy new versions and back up the database. Then we’ll look at the shortcut: pushing the same repository to Railyard and letting it read requirements.txt for you.
What you’ll need
- A DigitalOcean droplet. The Basic plan at 1 vCPU / 2 GB RAM runs Postgres, gunicorn and Caddy together comfortably and currently costs $12/month; the 1 GB plan ($6/month) works for very small apps but leaves little headroom once Postgres is also running.
- A domain name, with access to add an
Arecord. - A GitHub (or any Git host) repository containing your Django project.
- A DigitalOcean account with billing set up, and an SSH key added to your account.
Step 1: create the droplet
From the DigitalOcean dashboard, create a new droplet: choose the Ubuntu 24.04 LTS image, the 2 GB Basic plan, a region close to your users, and your SSH key. Once it’s running, note its public IP address and point your domain’s A record at it:
myproject.example.com. A 203.0.113.10
DNS propagation can take a few minutes to a few hours. You can continue setting up the server in the meantime; Caddy only needs the DNS record to be correct by the time it tries to issue a certificate.
SSH in as root and install Docker:
ssh root@203.0.113.10
curl -fsSL https://get.docker.com | sh
apt-get install -y docker-compose-plugin
Check it worked:
docker compose version
You should see a version string for the Compose plugin, not a “command not found” error.
Step 2: prepare the Django app for production
Django’s defaults assume a developer running manage.py runserver locally, not a server. A few settings need to change for production. In 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"
Pin the packages you need in requirements.txt. This guide uses Django 6.1, gunicorn 26.2 as the WSGI server, and psycopg2-binary 2.9.13 to talk to Postgres:
django==6.1.2
gunicorn==26.2.0
psycopg2-binary==2.9.13
If you already use django-environ or dj-database-url to read DATABASE_URL as a single string, that works too; the environment variables above are just the plainest version.
Step 3: write the 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"]
Replace myproject.wsgi with your actual project package. Running migrate before gunicorn starts means every deploy applies pending migrations automatically; for a single-server setup like this one, that’s simpler than a separate release step. Three gunicorn workers is a reasonable starting point for a 2 GB droplet; watch memory under load and adjust.
Step 4: write 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:
Only Caddy publishes ports to the outside world; web and db stay reachable only from containers on the same Compose network, which is one less thing exposed to the internet by accident.
Step 5: write the Caddyfile
myproject.example.com {
encode gzip
handle_path /static/* {
root * /srv/static
file_server
}
reverse_proxy web:8000
}
Caddy requests and renews a Let’s Encrypt certificate for myproject.example.com automatically the first time it starts, as long as the domain’s DNS already points at this server and ports 80 and 443 reach it. Static files under /static/ are served directly by Caddy from the shared volume; everything else is proxied to gunicorn.
Step 6: set your environment variables and secrets
Create a .env file next to docker-compose.yml, and keep it out of version control:
POSTGRES_DB=myproject
POSTGRES_USER=myproject
POSTGRES_PASSWORD=a-long-random-password
DJANGO_ALLOWED_HOSTS=myproject.example.com
SECRET_KEY=a-different-long-random-value
Generate both random values with openssl rand -base64 32 rather than typing something memorable; this database and this Django instance only need to be unguessable, not rememberable.
Step 7: open the firewall and deploy
ufw allow 22
ufw allow 80
ufw allow 443
ufw enable
Then, from the directory with your docker-compose.yml:
docker compose up -d --build
docker compose logs -f caddy
You should see Caddy log that it obtained a certificate for your domain, then docker compose logs -f web should show gunicorn booting and migrations applying. Visit https://myproject.example.com in a browser: you should see your app over HTTPS with a valid certificate, not a browser warning.
Backups and upgrades
Back up Postgres with a scheduled dump, written outside the container so a lost volume doesn’t take the backup with it:
docker compose exec -T db pg_dump -U myproject myproject > backup-$(date +%F).sql
Put that in a cron job and copy the result somewhere off the droplet (DigitalOcean Spaces, S3, or just scp to another machine). To upgrade the app, pull your latest code, rebuild, and bring the containers back up; the migrate step in the Dockerfile’s CMD runs again automatically:
git pull
docker compose up -d --build web
The shortcut: do it with Railyard
Everything above (the Dockerfile, gunicorn, the migration step, DATABASE_URL, the Caddy proxy and certificate) is what Railyard sets up automatically when you point it at the same repository. From its own detection rules: Django is recognized from requirements.txt, pyproject.toml, or a manage.py that imports Django; the release step runs python manage.py migrate --no-input before the new version takes traffic; the web process runs on gunicorn, bound to the port Railyard assigns; and a psycopg/psycopg2 dependency provisions a Postgres database automatically, with DATABASE_URL injected as an environment variable, no Dockerfile or Compose file required. collectstatic also runs automatically during the build, so WhiteNoise or a standard STATIC_ROOT setup works without writing that step yourself. See the Django docs for the exact rules, and Databases and backups for how the managed Postgres instance is backed up and restored.
To deploy this way: create a droplet or server through Railyard (DigitalOcean is one of the supported providers), then paste your repository’s Git URL, or add this to your README for one click:
[](https://app.railyard.run/deploy?repo=https://github.com/you/your-django-app)
The manual path above is still the right one to know. It’s what Railyard is doing under the hood, and understanding it means you’re never stuck if something needs a change the dashboard doesn’t expose yet.
Troubleshooting
Caddy never gets a certificate. Check that the domain’s A record actually resolves to the droplet (dig myproject.example.com) and that ports 80 and 443 are open in both the droplet’s firewall and DigitalOcean’s cloud firewall if you added one. Let’s Encrypt validates over plain HTTP first, so port 80 has to be reachable even though the final site is HTTPS-only.
“Bad Request (400)” from Django. This is almost always ALLOWED_HOSTS not matching the domain you’re visiting. Confirm DJANGO_ALLOWED_HOSTS in .env matches exactly, redeploy with docker compose up -d web, and check docker compose logs web.
Static files 404. collectstatic has to run before Caddy can serve anything from /static/, and the static_files volume has to be mounted in both the web and caddy services at the paths they each expect. If you changed STATIC_ROOT, update the Dockerfile and docker-compose.yml volume paths to match.
Postgres container won’t start, or data looks wiped after a restart. Check docker compose logs db for permission errors on /var/lib/postgresql/data; this usually means the named volume was removed and recreated (for example by docker compose down -v, which deletes volumes). Never run that flag unless you mean to delete the data.
Port 80 or 443 already in use. Another web server (often an nginx installed by a DigitalOcean one-click image) is already bound to it. Stop and disable it: systemctl stop nginx && systemctl disable nginx.
Gunicorn workers getting killed, or the droplet feels slow under load. On a 1 GB droplet, Postgres, three gunicorn workers and Caddy can compete for memory. Drop to two workers, add a swap file as a safety net, or move up to 2 GB if the app has real traffic.
FAQ
Do I need Docker, or can I run this with systemd and a virtualenv? Docker Compose keeps Postgres, the app and Caddy isolated and easy to move between servers, but a virtualenv with gunicorn run by systemd and a system-installed Postgres work the same way; the gunicorn command and Caddy configuration above don’t change either way.
Can I use SQLite instead of Postgres? For a single droplet with low write volume, yes, though you lose the ability to run more than one web container later and backups mean copying a single file instead of a dump. Postgres costs nothing extra here since it’s just another container.
What about Celery or background jobs? Add a worker service to docker-compose.yml running celery -A myproject worker --loglevel=info against the same image and .env file; it needs Redis or RabbitMQ as a broker, which is another container the same way Postgres is.
Do I need a bigger droplet if I add more apps later? Only once you’re actually running out of memory or CPU. One droplet can run several apps behind the same Caddy instance on different subdomains, each with its own certificate, the same way it runs one.
A Django app on a droplet isn’t fragile once the pieces are wired up correctly, and none of the pieces here are specific to Django: the same Postgres container, the same Caddy proxy and the same backup cron job work for the next app you put on this server too.
Why people pick Railyard for this
- Your own cloud account. Deploy to DigitalOcean, Hetzner, Vultr, AWS, OVHcloud, or any Linux server over SSH, billed directly by your provider with no markup.
- One flat price per team. Free is free forever: one server plus a database server, three apps, no card. Pro is $19 a month for up to five servers and unlimited apps. Team is $49 a month with unlimited servers. No per-app, per-deploy, per-seat or per-gigabyte charges.
- One-click templates. Deploy open-source apps, like databases, analytics and dev tools, to your own server in one click.
- Managed databases. Postgres, MySQL and Redis with scheduled, restore-verified backups and point-in-time recovery.
- Push-to-deploy. Deploy from GitHub, GitLab or the CLI, with automatic HTTPS and zero-downtime releases.