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
Upgradehandshake 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.
Quick answer
- Build
asgi.pywithProtocolTypeRouter, wrapping WebSocket routes inAllowedHostsOriginValidatorandAuthMiddlewareStack. - Configure
CHANNEL_LAYERSwithchannels_redis.core.RedisChannelLayerpointing at Redis. - Run Daphne under systemd on a Unix socket or a local port.
- Keep Gunicorn for HTTP, or serve everything from ASGI if you prefer one server.
- In Nginx, route
/ws/to Daphne withproxy_http_version 1.1,Upgrade, andConnectionheaders and a longproxy_read_timeout. - Use
wss://from the browser on HTTPS pages. - Verify the handshake returns
101 Switching Protocolsand 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))
),
}
)
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 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-headersmakes Daphne useX-Forwarded-ForandX-Forwarded-Protofrom 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;
}
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 Protocolsthrough Nginx- connections from an
Originnot inALLOWED_HOSTSare 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
LimitNOFILEin the Daphne unit andworker_connectionsplusworker_rlimit_nofilein 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_sendfans 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
awaitpoints.
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,
},
}
}
capacitylimits queued messages per channel; beyond it, sends raiseChannelFull, which protects Redis from a slow consumer.expirydrops messages not consumed within the given seconds.group_expiryremoves group memberships that were never discarded, for example after a Daphne crash skippeddisconnect.
Troubleshooting quick reference
| Browser or log symptom | Likely cause |
|---|---|
WebSocket connection failed, HTTP 404 in Nginx log | /ws/ location missing or path mismatch with URLRouter |
HTTP 400 or 403 during handshake | Origin not allowed by AllowedHostsOriginValidator |
Handshake returns 200 HTML | request went to Gunicorn, not Daphne; check location order |
502 on /ws/ | Daphne not running or socket permission denied |
| Connects, but messages never arrive | channel layer misconfigured or InMemoryChannelLayer in production |
AppRegistryNotReady at startup | consumers imported before get_asgi_application() |
Next steps
- If connections fail, work through Fix Django WebSocket Connection Failed Behind Nginx.
- Compare ASGI servers in Deploy Django ASGI with Uvicorn and Nginx.
- Run Redis reliably with Set Up Redis Caching for Django in Production.
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.