← Blog

Deploy Django to DigitalOcean

Railyard

May 1, 2026 · 8 min read

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 A record.
  • 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:

[![Deploy to Railyard](https://app.railyard.run/deploy/badge)](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.

Skip the droplet setup

Paste your Django repo into Railyard and gunicorn, migrations and Postgres are wired up for you.

Free plan, no card, and 14 days of Pro for every new team. One flat price per team.