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.
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.
First, add the required packages to your project (using uv, as this project does):
Or with plain pip:
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.
Before touching the VPS configuration, verify everything works locally:
Visit http://127.0.0.1:8000/ and confirm the site loads correctly, including static files and any AJAX-heavy pages.
Once you're confident the ASGI stack works, stop and disable the uWSGI service, and remove its site configuration:
You can also uninstall the uwsgi Python package from your virtual environment:
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.
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:
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:
- Check
sudo systemctl status gunicorn- it should beactive (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.
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).
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:
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:
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.serviceandgunicorn.serviceindependently, so restarting/updating one project never restarts the other. sudo systemctl status uwsgi gunicornandsudo nginx -tare your two best friends when something doesn't route correctly.
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.