Deploy Django Channels and WebSockets with Daphne

Deploy Django Channels in production with Daphne, a Redis channel layer, systemd, and Nginx WebSocket proxying, while Gunicorn keeps serving regular HTTP traffic.

Problem statement

Django Channels adds WebSockets and other long-lived connections to Django. Locally, runserver with daphne in INSTALLED_APPS makes everything work in one process. Production is different:

  • WSGI servers such as Gunicorn's sync workers cannot hold WebSocket connections
  • each server process has its own memory, so messages sent from one process never reach consumers in another without a channel layer
  • Nginx does not forward the WebSocket Upgrade handshake unless configured to
  • idle connections are closed by proxy timeouts after 60 seconds by default
  • origin and authentication checks that are easy to skip in development matter in production

This guide deploys a Channels application with Daphne handling WebSocket traffic under /ws/, Gunicorn continuing to serve normal HTTP, Redis as the channel layer, and Nginx routing between them.

WebSockets with Channels and DaphneBrowserwss:// connectionNginxupgrade handshakeDaphneASGI consumerRedis layergroup messagesGunicorn keeps serving HTTP while Daphne holds the long-lived connections.

Quick answer

  1. Build asgi.py with ProtocolTypeRouter, wrapping WebSocket routes in AllowedHostsOriginValidator and AuthMiddlewareStack.
  2. Configure CHANNEL_LAYERS with channels_redis.core.RedisChannelLayer pointing at Redis.
  3. Run Daphne under systemd on a Unix socket or a local port.
  4. Keep Gunicorn for HTTP, or serve everything from ASGI if you prefer one server.
  5. In Nginx, route /ws/ to Daphne with proxy_http_version 1.1, Upgrade, and Connection headers and a long proxy_read_timeout.
  6. Use wss:// from the browser on HTTPS pages.
  7. Verify the handshake returns 101 Switching Protocols and that messages cross processes.

Step-by-step solution

1. Install dependencies

Django==5.2.6
channels==4.3.1
channels-redis==4.3.0
daphne==4.2.1
gunicorn==23.0.0

Pin the versions you test with. Install Redis on the server or use a managed Redis service.

2. Configure asgi.py correctly

# config/asgi.py
import os

from django.core.asgi import get_asgi_application

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

from channels.auth import AuthMiddlewareStack  # noqa: E402
from channels.routing import ProtocolTypeRouter, URLRouter  # noqa: E402
from channels.security.websocket import AllowedHostsOriginValidator  # noqa: E402

import chat.routing  # noqa: E402

application = ProtocolTypeRouter(
    {
        "http": django_asgi_app,
        "websocket": AllowedHostsOriginValidator(
            AuthMiddlewareStack(URLRouter(chat.routing.websocket_urlpatterns))
        ),
    }
)
what wraps a WebSocket consumerProtocolTypeRouterhttp to Django, websocket belowAllowedHostsOriginValidatorrejects foreign Origin headersAuthMiddlewareStacksession cookie to scope["user"]URLRouterws/ paths to consumersConsumeryour connect, receive, disconnectget_asgi_application() must run before importing anything that touches modelsdjango-deployment.com
Dropping the origin validator exposes the socket to cross-site WebSocket hijacking.

get_asgi_application() must be called before importing modules that import models, because it initializes Django's app registry. Importing consumers first raises AppRegistryNotReady.

AllowedHostsOriginValidator rejects WebSocket connections whose Origin header does not match ALLOWED_HOSTS. Browsers do not apply the same-origin policy to WebSockets, so without this check any website could open an authenticated socket to your app using the visitor's session cookie.

3. Configure the channel layer

# settings.py
import os

CHANNEL_LAYERS = {
    "default": {
        "BACKEND": "channels_redis.core.RedisChannelLayer",
        "CONFIG": {
            "hosts": [os.environ.get("REDIS_URL", "redis://127.0.0.1:6379/1")],
        },
    }
}
the channel layer must be sharedInMemoryChannelLayerlives inside one process✕group messages miss other processes✕for tests and local work onlyRedisChannelLayer✓shared by every Daphne process✓workers and views can group_sendRedis must stay privateTwo Daphne processes on the in-memory layer never see each other’s groupsdjango-deployment.com
The symptom is messages that reach some browsers and not others.

The channel layer is how a view, a Celery task, or a consumer in one process sends messages to groups of connections held by other processes. InMemoryChannelLayer only works inside a single process and is for tests and development.

Use a Redis database number separate from your cache and Celery broker, or a separate Redis instance, so flushing one does not affect the others.

4. A consumer that checks authentication

# chat/consumers.py
from channels.generic.websocket import AsyncJsonWebsocketConsumer


class RoomConsumer(AsyncJsonWebsocketConsumer):
    async def connect(self):
        user = self.scope["user"]
        if not user.is_authenticated:
            await self.close(code=4401)
            return
        self.room = f"room_{self.scope['url_route']['kwargs']['room']}"
        await self.channel_layer.group_add(self.room, self.channel_name)
        await self.accept()

    async def disconnect(self, code):
        if hasattr(self, "room"):
            await self.channel_layer.group_discard(self.room, self.channel_name)

    async def receive_json(self, content):
        await self.channel_layer.group_send(
            self.room, {"type": "room.message", "text": str(content.get("text", ""))[:2000]}
        )

    async def room_message(self, event):
        await self.send_json({"text": event["text"]})

Validate input and enforce authorization per room; the WebSocket handshake does not pass through Django's view-level decorators.

5. Run Daphne with systemd

/etc/systemd/system/myapp-daphne.service:

[Unit]
Description=myapp Daphne (WebSockets)
After=network.target redis-server.service

[Service]
User=myapp
Group=www-data
WorkingDirectory=/srv/myapp/app
EnvironmentFile=/etc/myapp/env
RuntimeDirectory=myapp-daphne
ExecStart=/srv/myapp/venv/bin/daphne \
    --unix-socket /run/myapp-daphne/daphne.sock \
    --proxy-headers \
    --access-log - \
    config.asgi:application
Restart=on-failure
TimeoutStopSec=20

[Install]
WantedBy=multi-user.target
  • --proxy-headers makes Daphne use X-Forwarded-For and X-Forwarded-Proto from Nginx for the client address and scheme. Only enable it when Daphne is reachable solely through your proxy.
  • A Unix socket keeps the port off the network. Nginx on Ubuntu runs as www-data, which the socket's group permits.

Your existing Gunicorn service keeps serving HTTP with config.wsgi:application. Both processes share the same settings and channel layer.

sudo systemctl daemon-reload
sudo systemctl enable --now myapp-daphne
sudo systemctl status myapp-daphne

6. Route WebSockets in Nginx

Add a map in the http context (for example in /etc/nginx/conf.d/websocket-map.conf):

map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}
the headers a WebSocket needslocation /ws/ {proxy_pass http://127.0.0.1:8001;proxy_http_version1.1;proxy_set_headerUpgrade$http_upgrade;proxy_set_header Connection $connection_upgrade;proxy_read_timeout1h;}1231The upgrade handshake needs HTTP/1.1 to the upstream.2Hop-by-hop headers are dropped unless passed on explicitly.3The default 60 s closes quiet sockets; clients should still ping.Without these, the browser sees a failed handshake or a close after a minutedjango-deployment.com
The connection_upgrade variable comes from a map block in the http context.

In the site's HTTPS server block:

upstream myapp_http {
    server unix:/run/myapp/gunicorn.sock;
}

upstream myapp_ws {
    server unix:/run/myapp-daphne/daphne.sock;
}

server {
    listen 443 ssl;
    server_name www.example.com;
    # ssl_certificate lines from Certbot

    location /ws/ {
        proxy_pass http://myapp_ws;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $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;
        proxy_send_timeout 1h;
    }

    location / {
        proxy_pass http://myapp_http;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

Upgrade and Connection are hop-by-hop headers, so Nginx does not forward them unless you set them explicitly, and the upgrade requires HTTP/1.1 to the upstream. proxy_read_timeout closes connections that stay silent for longer than the value; either raise it as above or send periodic application-level pings from the client.

sudo nginx -t && sudo systemctl reload nginx

7. Connect from the browser

const scheme = window.location.protocol === "https:" ? "wss" : "ws";
const socket = new WebSocket(`${scheme}://${window.location.host}/ws/rooms/lobby/`);
socket.onmessage = (event) => console.log(JSON.parse(event.data));

Pages served over HTTPS must use wss://; browsers block ws:// from secure pages as mixed content.

8. Verify

Check the handshake through Nginx:

curl -i -N \
  -H "Connection: Upgrade" \
  -H "Upgrade: websocket" \
  -H "Sec-WebSocket-Version: 13" \
  -H "Sec-WebSocket-Key: SGVsbG8sIHdvcmxkIQ==" \
  -H "Origin: https://www.example.com" \
  https://www.example.com/ws/rooms/lobby/

An unauthenticated request should be accepted at the protocol level and then closed by your consumer; with a valid session cookie you should see HTTP/1.1 101 Switching Protocols. Then confirm cross-process delivery from a Django shell:

from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer

async_to_sync(get_channel_layer().group_send)(
    "room_lobby", {"type": "room.message", "text": "hello from the shell"}
)

A browser connected to the lobby room should receive the message, which proves the Redis channel layer connects separate processes.

Verification check:

  • 101 Switching Protocols through Nginx
  • connections from an Origin not in ALLOWED_HOSTS are rejected
  • messages sent from a shell or Celery task reach connected clients
  • connections survive longer than 60 seconds of inactivity

Deploys and restarts

Restarting Daphne closes every WebSocket connection. Clients must reconnect automatically with backoff and jitter so a deploy does not cause a thundering herd. Restart Daphne and Gunicorn separately, and only when WebSocket code or shared settings changed.

Rollback

Deploy the previous release, restart both services, and let clients reconnect. Channel layer messages are transient, so no data migration is needed for Redis.

Explanation

Splitting traffic by path lets each server do what it is good at: Gunicorn handles short HTTP requests with a well-understood worker model, and Daphne holds many concurrent, long-lived connections on an event loop. The Redis channel layer is the shared bus that lets any process address any group of connections. Nginx's job is to recognize the upgrade handshake, keep HTTP/1.1 to the upstream, and not time out quiet sockets.

Edge cases / notes

Serving everything from ASGI

You can run all traffic through Daphne or Uvicorn instead of Gunicorn. It simplifies routing, but synchronous Django views then run in a thread pool, and a slow sync view can affect WebSocket responsiveness.

Multiple Daphne processes

Run several Daphne instances on different sockets and list them in the myapp_ws upstream. Because state lives in the channel layer, any instance can serve any client.

Database access in consumers

Use database_sync_to_async for ORM calls in async consumers. Long-lived connections that hold database connections open can exhaust the database's connection limit.

Capacity planning for WebSockets

WebSocket capacity is limited by different resources than HTTP capacity:

  • Open file descriptors: each connection uses at least one descriptor in Nginx and one in Daphne. Raise LimitNOFILE in the Daphne unit and worker_connections plus worker_rlimit_nofile in Nginx for thousands of concurrent connections.
  • Memory per connection: consumer instances, their scope, and buffered messages live in Daphne's memory for the connection's lifetime. Measure memory with a realistic number of idle and active connections before launch.
  • Channel layer throughput: every group_send fans out through Redis. Large groups (thousands of members) multiply Redis traffic. Consider sharding groups or sending less frequent, aggregated updates.
  • Database connections: consumers that touch the ORM use connections from a thread pool. Close or reuse them carefully, and avoid holding transactions open across await points.

In the Daphne unit:

[Service]
LimitNOFILE=65536

In nginx.conf:

worker_rlimit_nofile 65536;

events {
    worker_connections 16384;
}

Channel layer tuning

channels_redis accepts options that matter under load:

CHANNEL_LAYERS = {
    "default": {
        "BACKEND": "channels_redis.core.RedisChannelLayer",
        "CONFIG": {
            "hosts": [os.environ["REDIS_URL"]],
            "capacity": 1500,
            "expiry": 10,
            "group_expiry": 86400,
        },
    }
}
  • capacity limits queued messages per channel; beyond it, sends raise ChannelFull, which protects Redis from a slow consumer.
  • expiry drops messages not consumed within the given seconds.
  • group_expiry removes group memberships that were never discarded, for example after a Daphne crash skipped disconnect.

Troubleshooting quick reference

when the socket will not stay openSymptomUsual causeHandshake returns 404Nginx sends /ws/ toGunicorn, not DaphneHandshake returns 403origin validator: hostmissing from ALLOWED_HOSTSCloses after about 60 sproxy_read_timeout left at the defaultSome clients miss messagesin-memory layer withmore than one processCheck the Daphne log first: a 404 that never appears there came from Gunicorndjango-deployment.com
Browser developer tools show the handshake status on the Network tab under WS.
Browser or log symptomLikely cause
WebSocket connection failed, HTTP 404 in Nginx log/ws/ location missing or path mismatch with URLRouter
HTTP 400 or 403 during handshakeOrigin not allowed by AllowedHostsOriginValidator
Handshake returns 200 HTMLrequest went to Gunicorn, not Daphne; check location order
502 on /ws/Daphne not running or socket permission denied
Connects, but messages never arrivechannel layer misconfigured or InMemoryChannelLayer in production
AppRegistryNotReady at startupconsumers imported before get_asgi_application()

Next steps

FAQ

Do I need Redis for Django Channels in production?

You need a channel layer that works across processes, and Redis through channels-redis is the standard choice. The in-memory layer only delivers messages within one process.

Can Gunicorn serve Django Channels WebSockets?

Not with its sync workers. Use an ASGI server such as Daphne or Uvicorn for WebSocket traffic. Gunicorn can keep serving regular HTTP requests alongside it.

Why do my WebSocket connections drop after about a minute?

Nginx's default proxy_read_timeout is 60 seconds, and it applies to idle WebSocket connections. Increase it for the WebSocket location or send periodic pings.

How do I secure Django WebSockets against cross-site connections?

Wrap the WebSocket router in AllowedHostsOriginValidator, authenticate users in connect, and check authorization for each group or room before accepting.

Keep going