Deployment
#django
#digitalocean
#gunicorn
#nginx

How to Deploy Django on a DigitalOcean Droplet

If you want to deploy Django on a DigitalOcean Droplet, you need more than python manage.py runserver.

Problem statement

If you want to deploy Django on a DigitalOcean Droplet, you need more than python manage.py runserver. runserver is for development only. It is not designed for process supervision, reverse proxying, TLS termination, static file delivery, or reliable restarts after crashes or reboots.

A production-safe Django deployment on a Droplet usually needs:

  • Ubuntu server
  • a non-root sudo user
  • Python virtual environment
  • PostgreSQL
  • Gunicorn
  • Nginx
  • systemd
  • HTTPS with Let's Encrypt

The goal is to host the Django app on a DigitalOcean Droplet in a way that survives restarts, serves traffic through a real web server, and keeps secrets out of source control. A basic manual deploy flow is included, with notes on safer rollback patterns for later.

Quick answer

For most projects, the simplest reliable stack is:

  • Ubuntu 22.04 or 24.04 on a DigitalOcean Droplet
  • Django app in /srv/myapp/current
  • Python virtualenv in /srv/myapp/.venv
  • PostgreSQL for the database
  • Gunicorn bound to 127.0.0.1:8000
  • Nginx reverse proxy on ports 80/443
  • systemd managing Gunicorn
  • Certbot issuing TLS certificates

High-level deploy flow:

  1. Create and harden the Droplet.
  2. Configure Django production settings.
  3. Install app dependencies in a virtualenv.
  4. Set up PostgreSQL.
  5. Run Gunicorn under systemd.
  6. Put Nginx in front of it.
  7. Enable HTTPS with Certbot.
  8. Deploy code, run migrations, collect static files, restart Gunicorn, and verify logs.

Step-by-step solution

1) Create and harden the DigitalOcean Droplet

Choose Ubuntu 22.04 LTS or 24.04 LTS. Add your SSH key during Droplet creation if possible.

SSH in as root once:

ssh root@your_server_ip

Create a deploy user and grant sudo:

adduser deploy
usermod -aG sudo deploy

Set up SSH keys for that user:

rsync --archive --chown=deploy:deploy ~/.ssh /home/deploy

Install base packages:

apt update && apt upgrade -y
apt install -y python3 python3-venv python3-pip nginx postgresql postgresql-contrib certbot python3-certbot-nginx ufw git

Configure the firewall:

ufw allow OpenSSH
ufw allow 'Nginx Full'
ufw enable
ufw status

Disable direct root SSH login in /etc/ssh/sshd_config:

PermitRootLogin no
PasswordAuthentication no

Then reload SSH:

systemctl reload ssh

Verification:

ssh deploy@your_server_ip
sudo ufw status

Rollback note: do not close your current SSH session until you confirm the deploy user can log in successfully.

2) Prepare the Django application for production

On the server, create an environment file:

sudo touch /etc/myapp.env
sudo chown root:deploy /etc/myapp.env
sudo chmod 640 /etc/myapp.env

Example /etc/myapp.env:

SECRET_KEY=replace-with-a-long-random-value
DEBUG=False
ALLOWED_HOSTS=example.com,www.example.com
CSRF_TRUSTED_ORIGINS=https://example.com,https://www.example.com
DATABASE_NAME=myapp
DATABASE_USER=myappuser
DATABASE_PASSWORD=strong-password
DATABASE_HOST=127.0.0.1
DATABASE_PORT=5432

Your Django settings should read from environment variables. At minimum, set:

  • DEBUG = False
  • ALLOWED_HOSTS
  • CSRF_TRUSTED_ORIGINS
  • PostgreSQL database settings
  • STATIC_ROOT
  • secret key from environment
  • secure proxy and cookie settings for HTTPS

Example settings pattern:

import os
from pathlib import Path

BASE_DIR = Path(__file__).resolve().parent.parent

DEBUG = os.environ.get("DEBUG", "False") == "True"
ALLOWED_HOSTS = [h for h in os.environ.get("ALLOWED_HOSTS", "").split(",") if h]
CSRF_TRUSTED_ORIGINS = [u for u in os.environ.get("CSRF_TRUSTED_ORIGINS", "").split(",") if u]

SECRET_KEY = os.environ["SECRET_KEY"]

DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.postgresql",
        "NAME": os.environ["DATABASE_NAME"],
        "USER": os.environ["DATABASE_USER"],
        "PASSWORD": os.environ["DATABASE_PASSWORD"],
        "HOST": os.environ.get("DATABASE_HOST", "127.0.0.1"),
        "PORT": os.environ.get("DATABASE_PORT", "5432"),
    }
}

STATIC_URL = "/static/"
STATIC_ROOT = BASE_DIR / "staticfiles"

MEDIA_URL = "/media/"
MEDIA_ROOT = BASE_DIR / "media"

SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True

# Enable HSTS only after HTTPS is working correctly everywhere.
SECURE_HSTS_SECONDS = 31536000
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = False

Run Django’s deployment checks later after dependencies are installed:

python manage.py check --deploy

3) Create the Python environment and install app dependencies

Switch to the deploy user:

ssh deploy@your_server_ip
mkdir -p /srv/myapp
cd /srv/myapp
python3 -m venv .venv
source .venv/bin/activate
pip install -U pip wheel

Deploy your code into /srv/myapp/current:

git clone https://your-repository-url /srv/myapp/current
source /srv/myapp/.venv/bin/activate
cd /srv/myapp/current
pip install -r requirements.txt

If you use PostgreSQL from Django, ensure your requirements include a PostgreSQL driver such as psycopg[binary] or psycopg2-binary, depending on your project.

Test Gunicorn directly before adding systemd:

source /srv/myapp/.venv/bin/activate
cd /srv/myapp/current
set -a
. /etc/myapp.env
set +a
gunicorn --bind 127.0.0.1:8000 myproject.wsgi:application

In another terminal:

curl -I http://127.0.0.1:8000

Stop Gunicorn with Ctrl+C.

4) Set up PostgreSQL

If PostgreSQL runs on the same Droplet:

sudo -u postgres psql

Create the database and user:

CREATE DATABASE myapp;
CREATE USER myappuser WITH PASSWORD 'strong-password';
ALTER ROLE myappuser SET client_encoding TO 'utf8';
ALTER ROLE myappuser SET default_transaction_isolation TO 'read committed';
ALTER ROLE myappuser SET timezone TO 'UTC';
GRANT ALL PRIVILEGES ON DATABASE myapp TO myappuser;

Exit with \q.

Apply migrations:

cd /srv/myapp/current
source /srv/myapp/.venv/bin/activate
set -a
. /etc/myapp.env
set +a
python manage.py migrate

Verification:

python manage.py showmigrations

Rollback note: if a migration fails, stop and fix it before continuing. For risky schema changes, take a PostgreSQL backup first:

pg_dump -Fc myapp > myapp-before-migration.dump

5) Configure Gunicorn with systemd

Create /etc/systemd/system/gunicorn.service:

[Unit]
Description=gunicorn daemon for Django app
After=network.target

[Service]
User=deploy
Group=www-data
WorkingDirectory=/srv/myapp/current
EnvironmentFile=/etc/myapp.env
Environment="PATH=/srv/myapp/.venv/bin"
ExecStart=/srv/myapp/.venv/bin/gunicorn --workers 3 --bind 127.0.0.1:8000 myproject.wsgi:application
Restart=always
RestartSec=3

[Install]
WantedBy=multi-user.target

Reload systemd and start the service:

sudo systemctl daemon-reload
sudo systemctl start gunicorn
sudo systemctl enable gunicorn
sudo systemctl status gunicorn

Check logs:

journalctl -u gunicorn -n 100 --no-pager

6) Configure Nginx as reverse proxy

Create /etc/nginx/sites-available/myapp:

server {
    listen 80;
    server_name example.com www.example.com;

    client_max_body_size 10M;

    location /static/ {
        alias /srv/myapp/current/staticfiles/;
    }

    location /media/ {
        alias /srv/myapp/current/media/;
    }

    location / {
        proxy_pass http://127.0.0.1:8000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

Enable the site:

sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx

Collect static files:

cd /srv/myapp/current
source /srv/myapp/.venv/bin/activate
set -a
. /etc/myapp.env
set +a
python manage.py collectstatic --noinput

Make sure Nginx can read static files, and media too if you serve uploads from local disk:

sudo chmod o+rx /srv /srv/myapp /srv/myapp/current
sudo find /srv/myapp/current/staticfiles -type d -exec chmod 755 {} \;
sudo find /srv/myapp/current/staticfiles -type f -exec chmod 644 {} \;

If you serve user uploads from /srv/myapp/current/media, apply equivalent readable permissions there as well.

Verification:

curl -I http://example.com
sudo systemctl status nginx

Rollback note: if nginx -t fails, restore the previous config before reloading.

7) Enable HTTPS with Let's Encrypt

Make sure your domain points to the Droplet IP first. Then run:

sudo certbot --nginx -d example.com -d www.example.com

Confirm renewal timer:

systemctl status certbot.timer

Verify HTTPS and redirect behavior:

curl -I http://example.com
curl -I https://example.com

At this point, verify that:

  • HTTP redirects to HTTPS
  • secure pages load without certificate warnings
  • login and form submissions still work
  • cookies are marked secure in the browser

8) Deploy application updates

A simple manual update flow looks like this:

cd /srv/myapp/current
git pull
source /srv/myapp/.venv/bin/activate
pip install -r requirements.txt
set -a
. /etc/myapp.env
set +a
python manage.py check --deploy
python manage.py migrate
python manage.py collectstatic --noinput
sudo systemctl restart gunicorn
sudo systemctl reload nginx

Post-deploy checks:

systemctl status gunicorn
systemctl status nginx
journalctl -u gunicorn -n 100 --no-pager
curl -I https://example.com

Also verify:

  • homepage loads
  • admin login works
  • forms submit correctly
  • static files load
  • media files load if applicable
  • ALLOWED_HOSTS and CSRF behavior are correct under your real domain

Rollback note: this in-place git pull method is simple, but not the safest rollback pattern. If a deploy breaks, you can check out the previous Git commit and restart Gunicorn, but that does not always fully reverse migrations or static asset changes. For lower-risk releases, move to versioned release directories and switch a current symlink only after verification.

When to automate this

Once you repeat this process across multiple apps or environments, the manual steps become good candidates for reusable scripts or templates. The first parts worth automating are server bootstrap, env file placement, systemd unit creation, Nginx site creation, and the deploy sequence of pull, install, migrate, collectstatic, and restart. That reduces drift between servers and makes rollback more predictable.

Explanation

Gunicorn and Nginx are a common Django production stack because they split responsibilities cleanly. Gunicorn runs the Python application. Nginx handles client connections, static files, request buffering, and TLS termination.

systemd is better than a shell session because it keeps Gunicorn running after logout, restarts it on failure, and starts it automatically on reboot.

Environment-based secrets are safer than hardcoding values in settings.py or committing them to Git. A restricted file like /etc/myapp.env is simple and works well on a single Droplet.

This setup is a good default when you want a straightforward DigitalOcean Django production deployment without introducing Docker or Kubernetes.

Edge cases and notes

  • No domain name yet: you can deploy using the Droplet IP, but HTTPS with Let’s Encrypt generally requires a domain name.
  • SQLite in production: possible for temporary internal tools, but limited for concurrent writes, backups, and scaling.
  • Database on same Droplet vs managed database: same-Droplet PostgreSQL is simpler and cheaper at small scale; managed PostgreSQL improves isolation, backups, and failover.
  • User-uploaded media: Nginx can serve local media from /media/, but object storage becomes a better option as uploads grow.
  • Migrations can break deploys: take backups before destructive changes, especially when removing columns or altering large tables.
  • Single Droplet limits: once CPU, memory, or deploy risk becomes an issue, separate the database, move media off-box, and consider load balancing across app servers.
  • HSTS caution: enable long HSTS values only after you confirm HTTPS works correctly for all required hostnames.

Before deploying, review a Django Deployment Checklist for Production to confirm hosts, secrets, cookies, TLS, and static configuration.

If you want a deeper breakdown of the web stack, see Deploy Django with Gunicorn and Nginx on Ubuntu.

If your project uses ASGI features, read Deploy Django ASGI with Uvicorn and Nginx.

For an alternative reverse proxy with simpler HTTPS management, see Deploy Django with Caddy and Automatic HTTPS.

If something fails during rollout, use a Django deployment troubleshooting checklist to work through 502s, missing static files, TLS problems, and startup errors.

FAQ

Can I deploy Django on the smallest DigitalOcean Droplet?

Yes, for small apps or low-traffic internal tools. But memory is often the first limit, especially with PostgreSQL, Gunicorn workers, and background tasks on the same server. Monitor RAM and swap usage early.

Do I need Docker to deploy Django on DigitalOcean?

No. A standard Ubuntu Droplet with virtualenv, Gunicorn, Nginx, systemd, and PostgreSQL is a normal production setup. Docker helps with consistency, but it is not required.

Should I use Gunicorn or Uvicorn on a DigitalOcean Droplet?

For a standard Django WSGI app, Gunicorn is the usual default. If your project depends on ASGI features such as WebSockets, use an ASGI-capable setup instead. For many Django apps, Gunicorn behind Nginx is the simplest stable choice.

Should PostgreSQL run on the same Droplet as Django?

It can, especially for smaller projects. That keeps setup simple. As the app grows, a managed database or separate database host usually gives better isolation, backups, and upgrade flexibility.

How do I update the app without noticeable downtime?

Keep Nginx running while you restart Gunicorn, and make deploys predictable: pull code, install dependencies, run migrations carefully, collect static files, then restart Gunicorn. For lower-risk releases, use versioned release directories and switch a symlink to the new release after verification.

2026 · django-deployment.com - Django Deployment knowledge base