Deployment
#django
#gunicorn
#nginx
#ubuntu

Deploy Django with Gunicorn and Nginx on Ubuntu

Moving a Django app from local development to production usually fails on the same points: process management, reverse proxy configuration, static files, secrets, TLS, and safe...

Problem statement

Moving a Django app from local development to production usually fails on the same points: process management, reverse proxy configuration, static files, secrets, TLS, and safe restarts. The Django development server is not suitable for public traffic, and a production deployment needs clear separation between the web server, app server, and application settings.

This guide shows a practical way to deploy Django with Gunicorn and Nginx on Ubuntu. It covers a single-server production pattern using systemd for process supervision, Nginx for reverse proxy and static files, and Gunicorn for running Django. It does not cover container orchestration or a full server hardening baseline.

Quick answer

To deploy Django with Gunicorn and Nginx on Ubuntu:

  1. install Ubuntu packages and Nginx
  2. create a non-root deploy user and app directories
  3. create a Python virtual environment and install dependencies
  4. configure Django production settings with environment variables
  5. run migrations and collect static files
  6. create a systemd Gunicorn service, usually bound to a Unix socket
  7. configure Nginx to proxy requests to Gunicorn and serve /static/ and /media/
  8. enable HTTPS with Let's Encrypt
  9. verify with curl, systemctl, journalctl, and Nginx logs

For safer rollback, keep the previous known-good commit, dependency set, and database backup or migration plan available until the new release is verified.

Step-by-step solution

Architecture for Django + Gunicorn + Nginx on Ubuntu

Request flow:

Browser → Nginx → Gunicorn → Django

  • Nginx terminates HTTP/HTTPS and serves static files directly.
  • Gunicorn runs the Django WSGI app.
  • Django talks to PostgreSQL and Redis if your app uses them.

A simple directory layout is enough:

/opt/myapp/
├── current/              # active code checkout
├── shared/
│   ├── .env
│   ├── static/
│   └── media/
└── venv/

This stack is a good fit for a single VM, small teams, and non-container deployments.

Prepare the Ubuntu server

Install base packages:

sudo apt update
sudo apt install -y python3-venv python3-pip python3-dev build-essential \
    libpq-dev nginx git ufw

If PostgreSQL is remote, libpq-dev is still useful when building PostgreSQL client dependencies.

Create a deploy user and app directories:

sudo adduser --disabled-password --gecos "" deploy
sudo mkdir -p /opt/myapp/shared/static /opt/myapp/shared/media
sudo chown -R deploy:deploy /opt/myapp

Basic firewall rules:

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

Verification:

nginx -v
python3 --version

Prepare the Django application for production

Get the code onto the server as the deploy user:

sudo -u deploy -H bash
cd /opt/myapp
git clone https://example.com/your-repo.git current
python3 -m venv /opt/myapp/venv
source /opt/myapp/venv/bin/activate
pip install --upgrade pip
pip install -r /opt/myapp/current/requirements.txt

Create an environment file outside the repository:

sudo tee /opt/myapp/shared/.env > /dev/null <<'EOF'
DJANGO_SECRET_KEY=replace-me
DJANGO_DEBUG=False
DJANGO_ALLOWED_HOSTS=example.com,www.example.com
DJANGO_CSRF_TRUSTED_ORIGINS=https://example.com,https://www.example.com
DATABASE_URL=postgres://user:pass@dbhost:5432/mydb
REDIS_URL=redis://127.0.0.1:6379/1
EOF

sudo chown deploy:deploy /opt/myapp/shared/.env
sudo chmod 600 /opt/myapp/shared/.env

Your Django settings must read these values from the environment. Production settings should include at least:

import os

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

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

STATIC_ROOT = "/opt/myapp/shared/static"
MEDIA_ROOT = "/opt/myapp/shared/media"

If you prefer to handle HTTP-to-HTTPS redirects only in Nginx, do that consistently and do not enable SECURE_SSL_REDIRECT. In this guide, Django is configured to treat proxied HTTPS requests as secure and redirect HTTP to HTTPS.

Run checks, migrations, and static collection:

cd /opt/myapp/current
source /opt/myapp/venv/bin/activate
set -a
. /opt/myapp/shared/.env
set +a
python manage.py check --deploy
python manage.py migrate
python manage.py collectstatic --noinput

Verification:

ls -la /opt/myapp/shared/static | head

Before running migrate on an important production system, make sure you have a recent backup and a clear rollback plan for schema changes. If migrations are destructive, code rollback alone may not be enough.

Configure Gunicorn with systemd

For Nginx on the same server, a Unix socket is a good default.

Create a systemd service:

sudo tee /etc/systemd/system/gunicorn-myapp.service > /dev/null <<'EOF'
[Unit]
Description=Gunicorn for myapp
After=network.target

[Service]
User=deploy
Group=www-data
WorkingDirectory=/opt/myapp/current
EnvironmentFile=/opt/myapp/shared/.env
RuntimeDirectory=gunicorn-myapp
RuntimeDirectoryMode=0755
ExecStart=/opt/myapp/venv/bin/gunicorn \
    --workers 3 \
    --bind unix:/run/gunicorn-myapp/gunicorn.sock \
    myproject.wsgi:application
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target
EOF

Replace myproject.wsgi:application with your actual Django WSGI path.

Enable and start the service:

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

Check logs:

sudo journalctl -u gunicorn-myapp -n 50 --no-pager

Verification:

sudo ls -l /run/gunicorn-myapp/gunicorn.sock
curl --unix-socket /run/gunicorn-myapp/gunicorn.sock http://localhost/

If Gunicorn fails here, do not continue to Nginx until systemctl status is clean and the socket test works.

Configure Nginx as the reverse proxy

Create an Nginx server block:

sudo tee /etc/nginx/sites-available/myapp > /dev/null <<'EOF'
server {
    listen 80;
    server_name example.com www.example.com;

    client_max_body_size 20M;

    location /static/ {
        alias /opt/myapp/shared/static/;
    }

    location /media/ {
        alias /opt/myapp/shared/media/;
    }

    location / {
        include proxy_params;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_pass http://unix:/run/gunicorn-myapp/gunicorn.sock:/;
    }
}
EOF

Enable the site and test the config:

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

Remove the default site if needed:

sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl reload nginx

Basic verification:

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

Add TLS with Certbot:

sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d example.com -d www.example.com

Then verify HTTPS:

curl -I https://example.com

Only enable HSTS after HTTPS is working correctly for all relevant hostnames.

Security checks for a production Django deployment

Confirm that debug mode is off and deploy checks pass:

cd /opt/myapp/current
source /opt/myapp/venv/bin/activate
set -a
. /opt/myapp/shared/.env
set +a
python manage.py check --deploy

If you trigger an error intentionally in a controlled way, Django should return a generic error page, not a debug traceback.

Keep secrets out of the repository and locked down on disk:

ls -l /opt/myapp/shared/.env

Expected permissions should be restrictive, such as 600.

Also confirm that HTTPS is actually in use:

curl -I https://example.com

Validate the deployment end to end

Browser and HTTP checks:

  • homepage loads over HTTPS
  • admin login page loads
  • CSS and JS return 200
  • HTTP redirects to HTTPS after Certbot changes
  • file uploads work if your app uses /media/

Service and log checks:

sudo systemctl status gunicorn-myapp
sudo systemctl status nginx
sudo journalctl -u gunicorn-myapp -n 50 --no-pager
sudo tail -n 50 /var/log/nginx/error.log
sudo tail -n 50 /var/log/nginx/access.log

A useful smoke test:

curl -I https://example.com
curl -I https://example.com/static/path-to-a-real-file.css

If you see 502 Bad Gateway, check the Gunicorn service status, socket path, and socket permissions first.

Rollback and recovery notes

For safer deploys, keep:

  • previous known-good commit or release
  • previous dependency set or lockfile
  • current .env
  • database backup or migration rollback plan
  • backups for uploaded media if your app accepts files

A simple rollback if the new code is bad:

  1. redeploy the previous known-good commit into /opt/myapp/current or switch back if you use a release symlink pattern
  2. reinstall prior dependencies if they changed
  3. restart Gunicorn
  4. verify the app with curl and logs

Example:

cd /opt/myapp/current
git checkout <previous-known-good-commit>
source /opt/myapp/venv/bin/activate
pip install -r requirements.txt
sudo systemctl restart gunicorn-myapp
sudo systemctl status gunicorn-myapp
curl -I https://example.com

If a migration changed schema destructively, code rollback alone may not be enough. Treat those releases separately and plan backups before deployment.

Explanation

This setup works because each component has a clear role. Gunicorn runs Django as a managed service under systemd, so it starts on boot and restarts on failure. Nginx handles client connections, static files, and TLS more efficiently than Gunicorn. Using a Unix socket keeps app traffic local to the host and avoids exposing Gunicorn directly.

Choose this approach when you want a stable Ubuntu deployment without containers. If you need multiple app servers, stronger release consistency across environments, or easier horizontal scaling, a container-based or load-balanced deployment may be a better fit.

When to turn this into a reusable script or template

This manual process is fine for a first production setup, but it becomes repetitive once you manage multiple apps or environments. The first pieces worth standardizing are the directory layout, systemd unit, Nginx server block, environment file schema, and post-deploy smoke checks. A release-based deploy pattern is also a good candidate for automation because it makes rollback safer.

Edge cases or notes

  • PostgreSQL on another host: allow network access only from the app server, and use SSL if required by your database service.
  • User uploads: Nginx can serve /media/, but you still need backups for uploaded files.
  • Socket permissions: if Nginx cannot reach the socket, check the Gunicorn service Group, runtime directory, and the socket path used in both configs.
  • Long requests: if requests time out, review Gunicorn worker count and Nginx timeout settings instead of increasing them blindly.
  • ASGI apps: for Django Channels or websockets, this Gunicorn WSGI setup is not enough; use an ASGI deployment path instead.

FAQ

Should Gunicorn bind to a Unix socket or a localhost TCP port on Ubuntu?

Use a Unix socket when Nginx and Gunicorn run on the same host. It is a common default and keeps Gunicorn local to the machine. Use 127.0.0.1:8000 if you prefer easier manual testing or have tooling that expects TCP.

Where should I store Django environment variables on a production Ubuntu server?

Store them outside the Git repository in a restricted file such as /opt/myapp/shared/.env or /etc/myapp.env. Limit permissions so only the deploy user and required services can read them.

Why does Nginx return 502 Bad Gateway after I start Gunicorn?

Usually one of these is wrong: Gunicorn is not running, the socket path does not match Nginx, Nginx lacks permission to access the socket, or the Django app failed to import because settings or dependencies are broken. Check systemctl status, journalctl -u gunicorn-myapp, and /var/log/nginx/error.log.

Do I need Nginx if Gunicorn can serve HTTP by itself?

For production, usually yes. Nginx handles TLS, static files, buffering, and connection management better. Gunicorn should focus on running the Django application.

What is the safest rollback option if a deploy breaks after migrations?

The safest option is a release-based deploy with the previous code still available, plus a tested backup and migration strategy. If migrations were destructive, restoring code alone may not recover the app.

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