Skip to content

Django with Nginx & Gunicorn on Debian 13 (Linode) ​

This setup uses Nginx as the web server and Gunicorn as the application server to deploy a Django Project. It uses PostgreSQL for the data models, and either PostgreSQL (database cache) or Redis for caching. It assumes that the local environment uses a similar setup.

NOTE

This setup uses a Public Subnet, so the Django server can be accessed over the internet.

NOTE

You can follow the steps in this guide as written, but replace the following placeholders with your own names:

  • <DJANGO Server IP Address>: Your Linode's Public IP Address
  • non_root: Your non-root username
  • your-name: Your name for git commits
  • your-email-id: Your email ID for git commits
  • <remote-URL>: Your GitHub repository's SSH clone URL
  • my-project: Your cloned project directory
  • my_project: Your Django project package (the folder containing wsgi.py)
  • api.example.com: Your domain name
  • info@example.com: Your admin email address

You should also update the IP addresses and VPC CIDR blocks, to match your VPC settings.

Requests flow through the server like this:

txt
Internet → Nginx (:443, TLS, static files) → /run/gunicorn.sock → Gunicorn (Django, as non_root)

(Optional) Create and set up new Django Project ​

If you don't already have a Django project created, you can use this guide to create and configure one.

Prepare the Project (Local Machine) ​

Make these changes locally, then commit and push them before setting up the server.

Add Gunicorn as a regular (not development) dependency, so it's installed on the server with uv sync --no-dev:

shell
uv add gunicorn

Nginx terminates HTTPS and forwards requests to Gunicorn over plain HTTP, so Django can't tell on its own that the original request was secure. In settings.py, add this line to the if not is_test: block:

python
if not is_test:
    # Trust Nginx's X-Forwarded-Proto header to detect HTTPS requests
    SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
    ...

WARNING

Without this line, Django treats every request as http. SECURE_SSL_REDIRECT then redirects every request to https forever, and CSRF checks fail. It's only safe because Nginx always overwrites this header (see the Nginx configuration below), so clients can't fake it.

Setup Linode (Debian) for Django ​

Launch a Linode ​

ParameterValue
Regionin-maa (Chennai)
OSDebian (Debian 13 as of 22-Feb-2026)
PlanNanode 1 GB (Shared CPU)
LabelGive your preferred label (Label can't have spaces)
Root PasswordCreate a Strong Password and store it somewhere safe
SSH KeysYou can add an existing SSH key or add this later when you deploy a new server
Disk EncryptionEnable
VPCSelect the VPC your other servers use (or create one if this is your first server)
SubnetSelect a public subnet since Django Server should be accessed via internet
Auto-assign a VPC IPv4Enable
Allow public IPv4 accessEnable
Network Interface TypeLinode Interfaces
VPC Interface FirewallCreate and assign a Firewall (that allows all outbound and no inbound - configured later in this guide)
BackupsDisable (The backups are useful only for databases)

Upgrade Packages ​

TIP

Use the LISH Console to connect to the Linode server. The firewall blocks all inbound traffic until you configure it, so you can't SSH in from your local machine yet.

Upgrade the packages on the server:

shell
sudo apt update && sudo apt upgrade -y

Set Timezone ​

Install all locales first to disable locale warnings:

shell
sudo apt install locales-all

All new Linode servers are set to UTC time by default. To change it to IST, use:

shell
timedatectl set-timezone 'Asia/Kolkata'

Confirm the date by running the date command in the terminal.

Configure Firewall ​

Add the following inbound rules to the Django Firewall:

Rule PurposeLabelProtocolPortsIP / NetmaskAction
Allow ICMP (ping) traffic from other serversChoose a labelICMPLeave blankVPC subnet IP range (Ex: 10.0.0.0/16)Accept
Allow HTTP traffic from the internetChoose a labelTCPHTTP (80)All IPv4, All IPv6Accept
Allow HTTPS traffic from the internetChoose a labelTCPHTTPS (443)All IPv4, All IPv6Accept
Allow SSH connections from admin systemsChoose a labelTCPSSH (22)Admin system's IP address (use /32)Accept

NOTE

Port 80 must stay open even after HTTPS is set up: Certbot uses it to renew certificates, and Nginx uses it to redirect http to https.

TIP

If your admin IP address changes (common with home internet connections), SSH will stop connecting. Log in through the LISH Console instead, and update the SSH rule with your new IP address.

Allow Outgoing Email ​

New Linode accounts block outgoing traffic on the SMTP ports (25, 465, and 587). Django sends error reports to ADMINS and any other emails (such as password resets) over SMTP, so without this step every email fails.

Open a support ticket in the Linode Cloud Manager asking to lift the SMTP restrictions for this Linode. Once support confirms, check that the port your email provider uses (465 in the project settings) is reachable:

shell
timeout 5 bash -c '</dev/tcp/smtpout.secureserver.net/465' && echo "SMTP port open"

Disable Root Login ​

IMPORTANT

The LISH Console does not use SSH, so PermitRootLogin no and PasswordAuthentication no do not apply to it. Anyone with access to your Linode account can reach a root login prompt through LISH using the root password. Your Linode account credentials and 2FA are therefore the real security perimeter for this server - enable 2FA and store the root password in a password manager.

First, create a limited user account:

shell
adduser non_root
# You'll be prompted to provide password

Add the new user to the sudo group for administrative privileges:

shell
adduser non_root sudo

Exit the session and SSH back into the server as your new user (from local Mac machine):

shell
exit
ssh non_root@<DJANGO Server IP Address>

Create an SSH directory and add the public key of your local Mac machine to the authorized keys file:

shell
mkdir ~/.ssh && chmod 700 ~/.ssh && vi ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys

Disable Root login and Password Authentication:

shell
sudo vi /etc/ssh/sshd_config
# Set `PermitRootLogin` to `no`
# Set `PasswordAuthentication` to `no`
# Set `AddressFamily` to `inet` (to disable IPv6 connections)

Validate the configuration before restarting, and keep your current session open in case something is wrong:

shell
sudo sshd -t

Confirm the settings sshd will actually use. Files in /etc/ssh/sshd_config.d/ are included at the top of sshd_config, and the first value read wins, so a drop-in file can silently override your edits:

shell
sudo sshd -T | grep -Ei 'permitrootlogin|passwordauthentication|addressfamily'
# Expected: permitrootlogin no, passwordauthentication no, addressfamily inet

Finally, restart the SSH service to apply the changes:

shell
sudo systemctl restart ssh

TIP

On Debian 13, SSH is socket-activated and the unit is named ssh. If you changed the listening address or port, also restart ssh.socket.

Create SSH Key for the VPC ​

The PostgreSQL (and Redis) server has no public IP, so you'll administer it over SSH from this server. Create a key pair for it:

shell
ssh-keygen -t ed25519 -C "django-server"

Note down this server's public key and its private IP address. The PostgreSQL guide asks for both - the key goes into the database server's authorized_keys, and the IP goes into its firewall rules and pg_hba.conf:

shell
cat ~/.ssh/id_ed25519.pub

# The private IP is the 10.x.x.x address (also shown in the Linode dashboard)
ip -4 -brief addr

Setup Database Server ​

At this point, if you don't have a Postgres Database server ready for production use, you'll need to set one up. You can follow this guide to configure it. In that guide, this Django server is the application server (the "other Linode").

IMPORTANT

When the PostgreSQL guide asks you to install ca.crt as the user your application runs as, run it as non_root on this server. Gunicorn runs Django as non_root, so PostgreSQL clients will find the certificate at /home/non_root/.postgresql/root.crt.

If the PostgreSQL server already exists, give this new server access to it instead:

  • Firewall - In the PostgreSQL server's firewall, add this server's private IP (use /32) to the SSH (22) and PostgreSQL (5432) rules. If it's in a different subnet, add that subnet to the ICMP rule too.
  • pg_hba.conf - Add a hostssl <your-database-name> <your-db-username> <this server's private IP>/32 scram-sha-256 line, then run sudo systemctl reload postgresql.
  • SSH key - Add this server's public key (from the step above) to ~/.ssh/authorized_keys of non_root on the PostgreSQL server, using the LISH Console or an existing application server.
  • CA certificate - Restore ca.crt from your offline storage to this server, then install it with install -D -m 644 ca.crt ~/.postgresql/root.crt && rm ca.crt.

(Optional) Redis for Caching ​

By default, the project uses PostgreSQL as the Django cache (DatabaseCache). If you'd rather use Redis, install it on this server with this guide once the rest of this guide is complete. Redis listens on localhost only, so it needs no firewall rule, TLS certificate, or access from the PostgreSQL server. The guide also covers the Django settings, the .env values, and a connection check.

Setup Django Project ​

uv ​

First, install the uv package manager:

shell
curl -LsSf https://astral.sh/uv/install.sh | sh

Load uv in every shell:

shell
# Added at the top of ~/.bashrc - Debian's ~/.bashrc stops early for non-interactive shells
# (such as `ssh server 'command'`), so lines at the end would be skipped there
sed -i '1i . "$HOME/.local/bin/env"' ~/.bashrc
source ~/.bashrc

uv --version

NOTE

Unlike mod_wsgi, Gunicorn runs from the project's virtual environment, so the project isn't tied to Debian's system Python. uv installs whichever Python version your project's .python-version and requires-python ask for.

Clone the GitHub project ​

Create a separate key pair for GitHub:

shell
ssh-keygen -t ed25519 -C "django-server-github" -f ~/.ssh/github_deploy

Tell SSH to use this key for GitHub:

shell
cat >> ~/.ssh/config <<EOF
Host github.com
    IdentityFile ~/.ssh/github_deploy
    IdentitiesOnly yes
EOF
chmod 600 ~/.ssh/config

Grab the generated public key and add it as a Deploy key in your GitHub repository's settings (Settings → Deploy keys → Add deploy key), leaving "Allow write access" unchecked:

shell
cat ~/.ssh/github_deploy.pub

TIP

A deploy key only grants read access to a single repository. Don't add this key to your GitHub account settings - an account key gives this server access to every repository you own, so a compromised server would expose all of them.

Install git to manage the GitHub repositories locally:

shell
sudo apt -y install git

Confirm git installation:

shell
git --version

Configure your git credentials locally:

shell
git config --global user.name "your-name"
git config --global user.email "your-email-id"

Clone the GitHub repository in your home directory on the Linux Server:

shell
cd ~ && git clone <remote-URL>

Setup Project on the Server ​

Navigate into your cloned project directory (e.g., cd ~/my-project). Create an .env file with variables that match your local setup but with updated production values:

  • Set DJ_ENV to PROD.
  • Set POSTGRES_HOST to the PostgreSQL server's private IP (it must match the certificate's SAN exactly).
  • Set POSTGRES_SSLMODE to verify-full, so Django checks the database server's certificate against ~/.postgresql/root.crt.
  • Add the AWS S3 and CloudFront values from this guide. In production, uploaded files (such as profile pictures) are stored in S3, so uploads fail without them.
  • If you use Redis for caching, add the Redis values from the Redis guide.

Generate a new secure SECRET_KEY using the following command and add it to your .env file:

shell
python3 -c "import secrets; print(secrets.token_urlsafe(64))"

Only non_root needs to read the .env file, so lock it down:

shell
chmod 600 .env

Create the virtual environment and install dependencies. --locked installs exactly what's in uv.lock (and fails if it's missing or out of date - so make sure uv.lock is committed to the repository), and --no-dev skips development-only packages:

shell
uv sync --locked --no-dev

# Confirm Gunicorn is installed
.venv/bin/gunicorn --version

Create the log directory. Django runs as non_root (both under Gunicorn and for management commands), so it needs to own the directory to create debug.log:

shell
sudo install -d -m 750 -o non_root -g non_root /logs

Gunicorn workers and management commands write to the same log file from different processes, so rotating it from inside Django is unsafe. In production, the project logs with WatchedFileHandler, and logrotate rotates the file instead:

shell
sudo tee /etc/logrotate.d/django > /dev/null <<'EOF'
/logs/debug.log {
    su non_root non_root
    create 0640 non_root non_root
    weekly
    rotate 8
    compress
    delaycompress
    missingok
    notifempty
}
EOF

# Check the configuration (dry run)
sudo logrotate -d /etc/logrotate.d/django

Collect the static files. Keep passing --no-dev to uv run - without it, uv run re-syncs the environment and installs the development packages again:

shell
uv run --no-dev manage.py collectstatic

Create the Django Cache table (skip this if you use Redis for caching):

shell
uv run --no-dev manage.py createcachetable

Check the project for any configuration errors before deploying:

shell
uv run --no-dev manage.py check
uv run --no-dev manage.py check --deploy

Confirm there are no model changes without a migration (migrations should be created locally and committed, never on the server), then run the migrations:

shell
uv run --no-dev manage.py makemigrations --check --dry-run # Should print "No changes detected"
uv run --no-dev manage.py migrate

Create a Super User, if your application needs one (for example, for admin-only API endpoints):

shell
uv run --no-dev manage.py createsuperuser

NOTE

The project's urls.py only enables the Django admin site when DEBUG is on, so there's no admin UI in production. The superuser logs in through your API's JWT endpoints like any other user.

Run Django with Gunicorn ​

Gunicorn runs as a systemd service. systemd owns the Unix socket (/run/gunicorn.sock) and hands it to Gunicorn, so:

  • Only Nginx (www-data) can connect to the socket, while Django itself runs as non_root.
  • While Gunicorn restarts (for example, on a deploy), new requests wait in the socket instead of failing.

Create the socket unit:

shell
sudo tee /etc/systemd/system/gunicorn.socket > /dev/null <<'EOF'
[Unit]
Description=Gunicorn socket for Django

[Socket]
ListenStream=/run/gunicorn.sock
# Gunicorn inherits the socket from systemd, so only Nginx needs access to it
SocketUser=www-data
SocketGroup=www-data
SocketMode=0660

[Install]
WantedBy=sockets.target
EOF

Create the service unit. --workers 3 suits a 1 GB Nanode (a common starting point is 2 × CPU cores + 1; each worker uses roughly 50–100 MB):

shell
sudo tee /etc/systemd/system/gunicorn.service > /dev/null <<'EOF'
[Unit]
Description=Gunicorn (WSGI) for Django
Requires=gunicorn.socket
After=network.target

[Service]
Type=notify
NotifyAccess=main
User=non_root
Group=non_root
WorkingDirectory=/home/non_root/my-project
ExecStart=/home/non_root/my-project/.venv/bin/gunicorn --workers 3 my_project.wsgi:application
ExecReload=/bin/kill -s HUP $MAINPID
KillMode=mixed
TimeoutStopSec=5
PrivateTmp=true
Restart=on-failure

[Install]
WantedBy=multi-user.target
EOF

Start Gunicorn now and at every boot:

shell
sudo systemctl daemon-reload
sudo systemctl enable --now gunicorn.socket gunicorn.service
sudo systemctl status gunicorn

Confirm Django answers on the socket. The Host and X-Forwarded-Proto headers stand in for Nginx, which isn't set up yet:

shell
sudo curl -sI --unix-socket /run/gunicorn.sock \
  -H "Host: api.example.com" -H "X-Forwarded-Proto: https" http://localhost/
# Any response from Django (such as 404 for `/` in an API-only project) means it works.
# A 400 means the Host isn't in ALLOWED_HOSTS; a 301 means SECURE_PROXY_SSL_HEADER is missing.

TIP

Gunicorn's own output (startup errors, worker crashes) goes to the journal: sudo journalctl -u gunicorn -n 50. Django's application logs still go to /logs/debug.log.

Install Nginx ​

Install Nginx and Certbot (Debian's certbot package also installs a systemd timer that renews certificates automatically):

shell
sudo apt -y install nginx certbot

You should now see the Nginx default page at http://<DJANGO Server IP Address>.

Grant Access to Static Files ​

Nginx (running as www-data) serves the static files directly, so it needs to read staticfiles, but nothing else in the project.

Debian 13 creates home directories with mode 700. Let the www-data group pass through (but not list) your home directory and the project directory:

shell
sudo chgrp www-data /home/non_root /home/non_root/my-project
chmod 710 /home/non_root /home/non_root/my-project

Remove access for everyone else from the project, then give the www-data group read access to the static files only:

shell
chmod -R o= /home/non_root/my-project

sudo chown -R non_root:www-data /home/non_root/my-project/staticfiles
chmod -R u=rwX,g=rX,o= /home/non_root/my-project/staticfiles

# New files from `collectstatic` inherit the www-data group
find /home/non_root/my-project/staticfiles -type d -exec chmod g+s {} +

Files created later (by git pull, uv sync, etc.) would still be readable by others under Debian's default umask of 022. Set a stricter umask for non_root, so new files are readable only by you and the group:

shell
sed -i '1i umask 027' ~/.bashrc
source ~/.bashrc

Get the SSL Certificate ​

At this point, you must point your domain to the current IP address using Route53 or a similar DNS service. Edit the A records for both domains.

txt
api.example.com
www.api.example.com

Get the certificate using the webroot method: Certbot places a file in /var/www/html (which the Nginx default site serves on port 80), and Let's Encrypt fetches it to confirm you own the domain. The --deploy-hook is saved, so Nginx reloads after every renewal to pick up the new certificate:

shell
sudo certbot certonly --webroot -w /var/www/html \
  -d api.example.com -d www.api.example.com \
  --deploy-hook "systemctl reload nginx"
# Details to be filled as follows:
#   * Email: `info@example.com`
#   * Terms of Service: `Y`
#   * Share Email Address: `N`

Confirm that automatic renewal works:

shell
sudo certbot renew --dry-run

NOTE

Unlike the Apache guide, Certbot here only gets the certificate (certonly) and doesn't edit the web server configuration. You write the complete Nginx configuration yourself in the next step, and it keeps serving /.well-known/acme-challenge/ from /var/www/html so renewals keep working.

Configure Nginx ​

Create the site configuration:

shell
sudo vi /etc/nginx/sites-available/my-project

Add the following configuration:

nginx
# Reject requests for any other host (such as the Linode IP address) - they never reach Django
server {
    listen 80 default_server;
    listen [::]:80 default_server;
    listen 443 ssl default_server;
    listen [::]:443 ssl default_server;

    ssl_reject_handshake on;
    return 444;
}

# HTTP: serve Certbot's renewal challenges, and redirect everything else to HTTPS
server {
    listen 80;
    listen [::]:80;
    server_name api.example.com www.api.example.com;

    location /.well-known/acme-challenge/ {
        root /var/www/html;
    }

    location / {
        return 301 https://api.example.com$request_uri;
    }
}

# HTTPS: redirect www to non-www
server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;
    server_name www.api.example.com;

    ssl_certificate     /etc/letsencrypt/live/api.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/api.example.com/privkey.pem;

    return 301 https://api.example.com$request_uri;
}

# HTTPS: the Django site
server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;
    server_name api.example.com;

    ssl_certificate     /etc/letsencrypt/live/api.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/api.example.com/privkey.pem;

    # Nginx's default limit is 1 MB, which is too small for most uploads (such as profile pictures)
    client_max_body_size 10M;

    location /static/ {
        alias /home/non_root/my-project/staticfiles/;
    }

    location / {
        proxy_pass http://unix:/run/gunicorn.sock;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        # Always overwritten here, so clients can't fake it (see SECURE_PROXY_SSL_HEADER)
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

WARNING

Never use root pointing at the project directory, or an alias for anything other than staticfiles. Nginx would then serve raw files - including .env, .git, and settings.py. Here, only /static/ is served from disk, and everything else goes to Django.

Replace the default site with yours, check the configuration for errors, and reload Nginx:

shell
sudo ln -s /etc/nginx/sites-available/my-project /etc/nginx/sites-enabled/
sudo rm /etc/nginx/sites-enabled/default

sudo nginx -t
sudo systemctl reload nginx

Now test the secure endpoint at https://api.example.com/. The www subdomain should correctly redirect to non-www, and all http traffic should force an upgrade to https.

Confirm that project files are not reachable, and that requests using the IP address are rejected (run from your local machine):

shell
# Should return Django's 404 - never the file contents
curl -sI https://api.example.com/.env

# Should fail with "Empty reply from server" (HTTP) and a TLS handshake error (HTTPS)
curl -I http://<DJANGO Server IP Address>/
curl -kI https://<DJANGO Server IP Address>/

TIP

Nginx logs to /var/log/nginx/access.log and /var/log/nginx/error.log. A 502 Bad Gateway means Nginx couldn't reach Gunicorn - check sudo systemctl status gunicorn and sudo journalctl -u gunicorn.

Deploy Updates ​

To deploy new code, run these commands on the server:

shell
cd ~/my-project
git pull
uv sync --locked --no-dev
uv run --no-dev manage.py migrate
uv run --no-dev manage.py collectstatic --noinput

# Restart Django - requests wait in the socket meanwhile, so none are dropped
sudo systemctl restart gunicorn

# If you run the RQ worker from the Redis guide, restart it too
sudo systemctl restart rqworker

NOTE

Restarting Gunicorn fully restarts the Django processes, so changes to .env and upgraded packages are picked up too. Only reload Nginx (sudo nginx -t && sudo systemctl reload nginx) if you changed its configuration.

Keep the Server Patched ​

This server is exposed to the internet, so install security updates automatically:

shell
sudo apt install -y unattended-upgrades needrestart
sudo dpkg-reconfigure -plow unattended-upgrades # Choose "Yes"

unattended-upgrades restarts most services itself, but a new kernel only takes effect after a reboot. Check periodically, and reboot if it reports an outdated kernel:

shell
sudo needrestart -k

You should now have a working Django server, securely connected to your database within the VPC.

(Optional) Add Uvicorn for WebSockets (ASGI) ​

Gunicorn (WSGI) can't hold WebSocket connections, which features like a chat app need. You don't need to switch the whole site to ASGI: keep regular requests on Gunicorn, and run a second, ASGI service - Gunicorn managing Uvicorn workers - for WebSocket paths (/ws/) only.

txt
/ws/...    → /run/asgi.sock     → Gunicorn + Uvicorn workers (my_project.asgi)
Everything else → /run/gunicorn.sock → Gunicorn (my_project.wsgi)

In the Project (Local Machine) ​

Add Django Channels, its Redis channel layer, and the Uvicorn worker. The channel layer passes messages between chat users connected to different workers, so it needs the Redis server (and the REDIS_URL defined in its Redis settings):

shell
uv add channels channels-redis uvicorn-worker 'uvicorn[standard]'

Add "channels" to INSTALLED_APPS, and the channel layer to settings.py (database 4, so it doesn't mix with the caches and the django-rq queues):

python
CHANNEL_LAYERS = {
    "default": {
        "BACKEND": "channels_redis.core.RedisChannelLayer",
        "CONFIG": {
            "hosts": [f"{REDIS_URL}/4"],
        },
    },
}

NOTE

Channel layer messages expire on their own (after 60 seconds by default), so they can't fill Redis under noeviction.

Route WebSockets in my_project/asgi.py. Your consumers and websocket_urlpatterns (with paths starting with ws/) come from the Channels tutorial:

python
import os

from channels.auth import AuthMiddlewareStack
from channels.routing import ProtocolTypeRouter, URLRouter
from channels.security.websocket import AllowedHostsOriginValidator
from django.core.asgi import get_asgi_application

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "my_project.settings")
django_asgi_app = get_asgi_application()

# Imported after Django is set up, since consumers use the models
from chat.routing import websocket_urlpatterns  # noqa: E402

application = ProtocolTypeRouter(
    {
        "http": django_asgi_app,
        # Only accepts connections from origins in ALLOWED_HOSTS
        "websocket": AllowedHostsOriginValidator(
            AuthMiddlewareStack(URLRouter(websocket_urlpatterns))
        ),
    }
)

Commit and push the changes, then deploy them on the server.

On the Server ​

Create the ASGI socket and service by copying the Gunicorn units and changing the socket path, application, and worker class:

shell
sed 's#/run/gunicorn.sock#/run/asgi.sock#; s#Gunicorn socket#ASGI socket#' \
  /etc/systemd/system/gunicorn.socket | sudo tee /etc/systemd/system/asgi.socket > /dev/null

sed 's#gunicorn.socket#asgi.socket#; s#Gunicorn (WSGI)#Gunicorn + Uvicorn (ASGI)#; s#--workers 3 my_project.wsgi:application#--workers 2 --worker-class uvicorn_worker.UvicornWorker my_project.asgi:application#' \
  /etc/systemd/system/gunicorn.service | sudo tee /etc/systemd/system/asgi.service > /dev/null

# Check the changed lines
grep -E 'ListenStream|Requires|ExecStart' /etc/systemd/system/asgi.*

sudo systemctl daemon-reload
sudo systemctl enable --now asgi.socket asgi.service

In /etc/nginx/sites-available/my-project, add this location to the Django site's server block (next to location /). WebSockets need HTTP/1.1 and the Upgrade headers, and a long read timeout so idle chats aren't disconnected after Nginx's default 60 seconds:

nginx
    location /ws/ {
        proxy_pass http://unix:/run/asgi.sock;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_read_timeout 1h;
    }

Check and reload Nginx:

shell
sudo nginx -t && sudo systemctl reload nginx

From now on, restart both services on every deploy:

shell
sudo systemctl restart gunicorn asgi

NOTE

Restarting asgi disconnects open WebSockets. Your frontend should reconnect automatically when a connection closes.