Django Deployment 2026-08-11

Migrating from uWSGI to Gunicorn + Uvicorn (ASGI) on a VPS

For a long time, uWSGI has been the workhorse WSGI application server for Django projects deployed on a VPS. It's battle-tested, fast, and integrates nicely with systemd and Nginx. However, WSGI is a synchronous protocol, which means it can't natively take advantage of Django's async views, WebSockets, or long-lived connections. If you want to move towards an ASGI-based stack while keeping the deployment simple and robust, Gunicorn combined with Uvicorn workers is one of the most popular and production-ready choices.

In this post, we'll walk through migrating a Django project from uWSGI to Gunicorn + Uvicorn on a typical Ubuntu VPS, updating systemd, and Nginx along the way.

Why Switch to ASGI?

  • Async views and middleware - Django has supported async views since 3.1, but they only run efficiently under an ASGI server.
  • WebSockets support - if you plan to add Django Channels for real-time features, you need an ASGI server anyway.
  • Better concurrency for I/O-bound workloads - async workers can handle many concurrent slow requests (external API calls, streaming responses) with fewer resources.
  • Future-proofing - the Django ecosystem is steadily moving towards ASGI as the default.
Why Gunicorn + Uvicorn?

Uvicorn is a lightning-fast ASGI server built on uvloop and httptools, but running it alone in production lacks some of the process-management features that Gunicorn provides out of the box (graceful reloads, worker management, robust logging, and easy integration with systemd). The recommended pattern is to let Gunicorn manage the worker processes, while each worker uses the UvicornWorker class to actually speak ASGI.

1. Update Dependencies

First, add the required packages to your project (using uv, as this project does):

uv add gunicorn uvicorn[standard] uv export --format requirements.txt --output-file requirements.txt

Or with plain pip:

pip install gunicorn "uvicorn[standard]"
2. Make Sure `asgi.py` Is Ready

Every Django project generated with startproject already ships an asgi.py file, similar to this one:


# core/asgi.py
import os

from django.core.asgi import get_asgi_application

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "core.settings")

application = get_asgi_application()
              

No changes are usually required here unless you're adding Django Channels routing later on.

3. Test Gunicorn + Uvicorn Locally

Before touching the VPS configuration, verify everything works locally:

source .venv/bin/activate gunicorn core.asgi:application -k uvicorn.workers.UvicornWorker --bind 0.0.0.0:8000 [INFO] Starting gunicorn 22.0.0 [INFO] Listening at: http://0.0.0.0:8000 [INFO] Using worker: uvicorn.workers.UvicornWorker

Visit http://127.0.0.1:8000/ and confirm the site loads correctly, including static files and any AJAX-heavy pages.

4. Remove uWSGI

Once you're confident the ASGI stack works, stop and disable the uWSGI service, and remove its site configuration:

sudo systemctl stop uwsgi sudo systemctl disable uwsgi sudo rm /etc/uwsgi/sites/scientificdev.ini

You can also uninstall the uwsgi Python package from your virtual environment:

pip uninstall -y uwsgi

Note: the steps above assume you want to drop uWSGI entirely. If you'd rather keep uWSGI around to serve a different project on the same VPS, skip this step and jump straight to the Running Both uWSGI and Gunicorn + Uvicorn on the Same VPS section below.

5. Create a systemd Service for Gunicorn

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


[Unit]
Description=Gunicorn daemon for scientific_dev (ASGI via Uvicorn workers)
After=network.target

[Service]
User=ubuntu
Group=www-data
WorkingDirectory=/home/ubuntu/PROJECTS/scientific_dev
EnvironmentFile=/home/ubuntu/PROJECTS/scientific_dev/.env
ExecStart=/home/ubuntu/PROJECTS/scientific_dev/.venv/bin/gunicorn \
          core.asgi:application \
          -k uvicorn.workers.UvicornWorker \
          --workers 3 \
          --bind unix:/home/ubuntu/PROJECTS/scientific_dev/gunicorn.sock \
          --access-logfile /home/ubuntu/PROJECTS/scientific_dev/logs/gunicorn-access.log \
          --error-logfile /home/ubuntu/PROJECTS/scientific_dev/logs/gunicorn-error.log

[Install]
WantedBy=multi-user.target
              

A good starting point for the number of workers is (2 x CPU cores) + 1, then tune based on load testing.

Enable and start the new service:

sudo systemctl daemon-reload sudo systemctl start gunicorn sudo systemctl enable gunicorn sudo systemctl status gunicorn ● gunicorn.service - Gunicorn daemon for scientific_dev (ASGI via Uvicorn workers) Active: active (running)
6. Update the Nginx Configuration

Point Nginx to the Gunicorn Unix socket instead of the uWSGI one. Edit /etc/nginx/sites-available/scientific_dev.conf:


server {
    listen 80;
    server_name scientific-dev.example.com;

    location /static/ {
        alias /home/ubuntu/PROJECTS/scientific_dev/static/;
    }

    location /media/ {
        alias /home/ubuntu/PROJECTS/scientific_dev/media/;
    }

    location / {
        include proxy_params;
        proxy_pass http://unix:/home/ubuntu/PROJECTS/scientific_dev/gunicorn.sock;
    }
}
              

Test and reload Nginx:

sudo nginx -t && sudo nginx -s reload nginx: the configuration file /etc/nginx/nginx.conf syntax is ok nginx: configuration file /etc/nginx/nginx.conf test is successful
7. Verify Everything Works
  • Check sudo systemctl status gunicorn - it should be active (running).
  • Tail the logs: tail -f logs/gunicorn-error.log.
  • Visit your domain in a browser and click through key pages, forms, and static assets.
  • If you use Celery or Supervisor for background tasks, they don't need any changes - only the web server layer changes.
8. Running Both uWSGI and Gunicorn + Uvicorn on the Same VPS

You don't have to migrate every project on a VPS at once. It's perfectly fine - and quite common - to keep an older Django project running under uWSGI while a newer one is served with Gunicorn + Uvicorn, side by side on the same machine. As long as each service binds to its own socket and Nginx routes requests to the right one based on the domain (or path), there's no conflict at all.

Let's assume two projects live under /home/ubuntu/PROJECTS/:

  • legacy_project - an older Django app, kept on uWSGI (WSGI).
  • scientific_dev - our newer Django app, migrated to Gunicorn + Uvicorn (ASGI).

8.1 Keep the uWSGI Service for the Legacy Project

The uWSGI Emperor mode can manage multiple .ini files at once, one per project, so the existing setup barely changes. Create/keep /etc/uwsgi/sites/legacy_project.ini:


base = /home/ubuntu/PROJECTS/legacy_project
home = /home/ubuntu/PROJECTS/legacy_project/.venv
env = DJANGO_SETTINGS_MODULE=core.settings
module = core.wsgi:application
socket = /home/ubuntu/PROJECTS/legacy_project/uwsgi.sock
chmod-socket = 664
vacuum = true
              

The uwsgi.service (Emperor) keeps watching the whole /etc/uwsgi/sites directory, so no changes are required there - it will pick up legacy_project.ini automatically and leave scientificdev.ini alone (since we already removed it in step 4 for this project).

sudo systemctl restart uwsgi sudo systemctl status uwsgi ● uwsgi.service Active: active (running)

8.2 Run Gunicorn + Uvicorn for the New Project

The gunicorn.service created in step 5 already binds to its own Unix socket, /home/ubuntu/PROJECTS/scientific_dev/gunicorn.sock, which is completely independent from the uWSGI socket used by legacy_project. Both services can be enabled and running at the same time:

sudo systemctl status uwsgi gunicorn ● uwsgi.service - active (running) ● gunicorn.service - active (running)

8.3 Route Each Domain in Nginx

Keep two separate server blocks (or two files under /etc/nginx/sites-available/), one per project, each pointing to its own socket:


# /etc/nginx/sites-available/legacy_project.conf (uWSGI / WSGI)
server {
    listen 80;
    server_name legacy-project.example.com;

    location /static/ {
        alias /home/ubuntu/PROJECTS/legacy_project/static/;
    }

    location /media/ {
        alias /home/ubuntu/PROJECTS/legacy_project/media/;
    }

    location / {
        include uwsgi_params;
        uwsgi_pass unix:/home/ubuntu/PROJECTS/legacy_project/uwsgi.sock;
    }
}
              

# /etc/nginx/sites-available/scientific_dev.conf (Gunicorn + Uvicorn / ASGI)
server {
    listen 80;
    server_name scientific-dev.example.com;

    location /static/ {
        alias /home/ubuntu/PROJECTS/scientific_dev/static/;
    }

    location /media/ {
        alias /home/ubuntu/PROJECTS/scientific_dev/media/;
    }

    location / {
        include proxy_params;
        proxy_pass http://unix:/home/ubuntu/PROJECTS/scientific_dev/gunicorn.sock;
    }
}
              

Enable both sites and reload Nginx:

sudo ln -s /etc/nginx/sites-available/legacy_project.conf /etc/nginx/sites-enabled/ sudo ln -s /etc/nginx/sites-available/scientific_dev.conf /etc/nginx/sites-enabled/ sudo nginx -t && sudo nginx -s reload nginx: configuration file /etc/nginx/nginx.conf test is successful

Because Nginx dispatches traffic purely based on the server_name (or a path prefix, if you prefer that instead of separate subdomains), each project's requests only ever reach its own application server. uWSGI and Gunicorn+Uvicorn never talk to each other and don't need to know the other one exists.

8.4 Quick Sanity Checklist

  • Each project has its own virtual environment, so dependency versions (Django, uWSGI/Gunicorn, etc.) never clash.
  • Each project binds to a distinct Unix socket (or, alternatively, a distinct TCP port) - never reuse the same socket path for two services.
  • Systemd manages uwsgi.service and gunicorn.service independently, so restarting/updating one project never restarts the other.
  • sudo systemctl status uwsgi gunicorn and sudo nginx -t are your two best friends when something doesn't route correctly.
Optional: Adding Django Channels Later

One of the biggest benefits of already running on Gunicorn + Uvicorn is that adding real-time features via Django Channels becomes much simpler down the road - you'd just extend asgi.py with a ProtocolTypeRouter and add a channel layer (e.g., Redis), without having to touch the process manager or Nginx socket setup again.

Conclusion

Switching from uWSGI to Gunicorn + Uvicorn is a low-risk, high-value change for a Django VPS deployment. It keeps the familiar systemd + Nginx workflow while unlocking ASGI capabilities such as async views and, eventually, WebSockets through Django Channels. The migration boils down to swapping the process manager and the socket target in Nginx - everything else, including your Celery workers and PostgreSQL setup, stays exactly the same.


Scientific Dev
Scientific Dev
Educator & Developer