Welcome to Django Channels tutorial.

In this tutorial we will create a Chat room app.

Docker must be installed on your computer in order to follow this tutorial.

We will develop the app locally and then deploy it on a server with Appliku.

Source code repository

The code for this tutorial is available on GitHub appliku/channelstutorial

Starting a Django Channels Chat Project with Docker

mkdir channelstutorial
cd channelstutorial

Initialize git repository right away with:

git init

Create a Dockerfile for Django Channels

In the root of your project create Dockerfile:

FROM python:3.12.8-bullseye
SHELL ["/bin/bash", "--login", "-c"]
ARG USER_ID=1000
ARG GROUP_ID=1000
RUN groupadd -g $USER_ID -o app
RUN useradd -m -u $USER_ID -g $GROUP_ID -o -s /bin/bash app
ENV PIP_NO_CACHE_DIR off
ENV PIP_DISABLE_PIP_VERSION_CHECK on
ENV PYTHONUNBUFFERED 1
ENV PYTHONDONTWRITEBYTECODE 1
ENV COLUMNS 80
RUN apt-get update \
 && apt-get install -y --force-yes \
 curl nano python3-pip gettext chrpath libssl-dev libxft-dev \
 libfreetype6 libfreetype6-dev  libfontconfig1 libfontconfig1-dev \
  && rm -rf /var/lib/apt/lists/*
WORKDIR /code/
COPY requirements.txt /code/
RUN pip install wheel
RUN pip install -r requirements.txt
COPY . /code/
USER app

Requirements for Django Channels

In the root of the project create a file requirements.txt:

Django==5.1.4
django-environ==0.11.2
gunicorn==23.0.0
psycopg[binary]==3.2.3
whitenoise==6.8.2
Pillow==11.0.0
channels[daphne]==4.2.0
channels-redis==4.2.1
asgiref==3.8.1

docker-compose.yml for Django Channels

In the root of the project create a file docker-compose.yml:

x-app: &app
  build: .
  restart: always
  env_file:
    - .env
  volumes:
    - .:/code
  links:
    - db
    - redis
  depends_on:
    - db
    - redis

services:
  redis:
    image: redis:7
    ports:
      - "6379:6379"
  db:
    image: postgres:16
    environment:
      - POSTGRES_USER=channelstutorial
      - POSTGRES_PASSWORD=channelstutorial
      - POSTGRES_DB=channelstutorial
    ports:
      - "5432:5432"
  web:
    <<: *app
    command: python manage.py runserver 0.0.0.0:8000
    ports:
      - "127.0.0.1:8000:8000"

.env file

In the root of the project create a file .env:

DATABASE_URL=postgresql://channelstutorial:channelstutorial@db:5432/channelstutorial
REDIS_URL=redis://redis:6379/0
DEBUG=True

.gitignore file

In the root of the repo create a file .gitignore

env/
.idea/
__pycache__/
*.py[cod]
*$py.class
.vscode/
.DS_Store  
.AppleDouble  
.LSOverride
.env

Pull existing Docker images and Build Django Image

Running this command will make Docker download images for services with defined image and not require the build.

This step is optional because they will be pulled anyway on the start of containers.

docker compose pull

Build your project's Docker image with:

docker compose build

Start Django Project in Docker

Now that our image is built, start a shell within a container and start our Django project

docker compose run web bash
django-admin startproject project .

Edit project/settings.py

For detailed information about settings in Django project read How to Deploy a Django Project

Here is the project/settings.py from that guide and then we'll be adding and changing settings for Django Channels specifically:

from pathlib import Path
import environ
import os

env = environ.Env(
    # set casting, default value
    DEBUG=(bool, False)
)

BASE_DIR = Path(__file__).resolve().parent.parent
# Take environment variables from .env file
environ.Env.read_env(os.path.join(BASE_DIR, ".env"))

SECRET_KEY = env("SECRET_KEY", default="change_me")

DEBUG = env("DEBUG", default=False)

ALLOWED_HOSTS = env.list("ALLOWED_HOSTS", default=["*"])
# Application definition

INSTALLED_APPS = [
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
]

MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "whitenoise.middleware.WhiteNoiseMiddleware",
    "django.contrib.sessions.middleware.SessionMiddleware",
    "django.middleware.common.CommonMiddleware",
    "django.middleware.csrf.CsrfViewMiddleware",
    "django.contrib.auth.middleware.AuthenticationMiddleware",
    "django.contrib.messages.middleware.MessageMiddleware",
    "django.middleware.clickjacking.XFrameOptionsMiddleware",
]

ROOT_URLCONF = "project.urls"

TEMPLATES = [
    {
        "BACKEND": "django.template.backends.django.DjangoTemplates",
        "DIRS": [],
        "APP_DIRS": True,
        "OPTIONS": {
            "context_processors": [
                "django.template.context_processors.debug",
                "django.template.context_processors.request",
                "django.contrib.auth.context_processors.auth",
                "django.contrib.messages.context_processors.messages",
            ],
        },
    },
]

WSGI_APPLICATION = "project.wsgi.application"


# Database
# https://docs.djangoproject.com/en/5.0/ref/settings/#databases

DATABASES = {
    "default": env.db(default="sqlite:///db.sqlite3"),
}
# Password validation
# https://docs.djangoproject.com/en/5.0/ref/settings/#auth-password-validators

AUTH_PASSWORD_VALIDATORS = [
    {
        "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
    },
    {
        "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
    },
    {
        "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
    },
    {
        "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
    },
]
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
LOGGING = {
    "version": 1,
    "disable_existing_loggers": False,
    "handlers": {"console": {"class": "logging.StreamHandler"}},
    "loggers": {"": {"handlers": ["console"], "level": "DEBUG"}},
}

# Internationalization
# https://docs.djangoproject.com/en/5.0/topics/i18n/

LANGUAGE_CODE = "en-us"

TIME_ZONE = "UTC"

USE_I18N = True

USE_TZ = True


# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/5.0/howto/static-files/

STATIC_URL = env.str("STATIC_URL", default="/static/")
STATIC_ROOT = env.str("STATIC_ROOT", default=BASE_DIR / "staticfiles")

WHITENOISE_USE_FINDERS = True
WHITENOISE_AUTOREFRESH = DEBUG


# Default primary key field type
# https://docs.djangoproject.com/en/5.0/ref/settings/#default-auto-field

DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"


MEDIA_ROOT = env("MEDIA_ROOT", default=BASE_DIR / "media")
MEDIA_URL = env("MEDIA_PATH", default="/media/")

Add daphne and channels to INSTALLED_APPS and set ASGI_APPLICATION:

INSTALLED_APPS = [
    "daphne",
    "channels",
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
]

ASGI_APPLICATION = "project.asgi.application"

Change the file project/asgi.py to this:

import os

from channels.routing import ProtocolTypeRouter
from django.core.asgi import get_asgi_application

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'project.settings')
# Initialize Django ASGI application early to ensure the AppRegistry
# is populated before importing code that may import ORM models.
django_asgi_app = get_asgi_application()

application = ProtocolTypeRouter({
    "http": django_asgi_app,
    # Just HTTP for now. (We can add other protocols later.)
})

In a new terminal, run this command to start all our containers:

docker compose up -d

This starts all containers and exists. You can run it without -d flag to see all the logs and CTRL-C will stop them.

To show logs for running containers run:

docker compose logs -f

Open browser at http://127.0.0.1:8000/ to make sure the project is running.

Django Project Start Page

Chat Django Application

Create Chat Django Application

Start a new Django application chat.

Reminder: we do it within docker container shell.

If you closed it then start again with docker compose run web bash and run:

python manage.py startapp chat

Add our app to INSTALLED_APPS in project/settings.py

INSTALLED_APPS = [
    "daphne",
    "channels",
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
    "chat",  # new
]

Create a folder chat/templates/chat/ and a file index.html in this folder with the following content:

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8"/>
    <title>Chat Rooms</title>
</head>
<body>
    What chat room would you like to enter?<br>
    <input id="room-name-input" type="text" size="100"><br>
    <input id="room-name-submit" type="button" value="Enter">

    <script>
        document.querySelector('#room-name-input').focus();
        document.querySelector('#room-name-input').onkeyup = function(e) {
            if (e.key === 'Enter') {  // enter, return
                document.querySelector('#room-name-submit').click();
            }
        };

        document.querySelector('#room-name-submit').onclick = function(e) {
            var roomName = document.querySelector('#room-name-input').value;
            window.location.pathname = '/chat/' + roomName + '/';
        };
    </script>
</body>
</html>

Edit chat/views.py:

from django.shortcuts import render


def index(request):
    return render(request, "chat/index.html")

Create a file chat/urls.py:

from django.urls import path

from . import views


urlpatterns = [
    path("", views.index, name="index"),
]

Edit the main URLConf in project/urls.py:

from django.contrib import admin
from django.urls import include, path
from django.views.generic import RedirectView # new

urlpatterns = [
    path("", RedirectView.as_view(url="chat/")),  # new
    path("chat/", include("chat.urls")), # new
    path("admin/", admin.site.urls),
]

This adds our chat page and redirect to the chat page from the root URL.

Now when we refresh the browser it should show this page:

chat room selection django channels tutorial

Create the room page

Create a template chat/templates/chat/room.html:

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8"/>
    <title>Chat Room</title>
</head>
<body>
    <textarea id="chat-log" cols="100" rows="20"></textarea><br>
    <input id="chat-message-input" type="text" size="100"><br>
    <input id="chat-message-submit" type="button" value="Send">
    {{ room_name|json_script:"room-name" }}
    <script>
        const roomName = JSON.parse(document.getElementById('room-name').textContent);

        const protocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
        const chatSocket = new WebSocket(
            protocol
            + window.location.host
            + '/ws/chat/'
            + roomName
            + '/'
        );
        chatSocket.onmessage = function(e) {
            const data = JSON.parse(e.data);
            document.querySelector('#chat-log').value += (data.message + '\n');
        };

        chatSocket.onclose = function(e) {
            console.error('Chat socket closed unexpectedly');
        };

        document.querySelector('#chat-message-input').focus();
        document.querySelector('#chat-message-input').onkeyup = function(e) {
            if (e.key === 'Enter') {  // enter, return
                document.querySelector('#chat-message-submit').click();
            }
        };

        document.querySelector('#chat-message-submit').onclick = function(e) {
            const messageInputDom = document.querySelector('#chat-message-input');
            const message = messageInputDom.value;
            chatSocket.send(JSON.stringify({
                'message': message
            }));
            messageInputDom.value = '';
        };
    </script>
</body>
</html>

Update chat/views.py adding the room view:

from django.shortcuts import render


def index(request):
    return render(request, "chat/index.html")


def room(request, room_name):
    return render(request, "chat/room.html", {"room_name": room_name})

Add the new URL route to chat/urls.py

from django.urls import path

from . import views

urlpatterns = [
    path("", views.index, name="index"),
    path("<str:room_name>/", views.room, name="room"), # new
]

Now if we try to enter the room name and click Enter we will see the room page.

room page in django channels chat app tutorial

In browser's dev tools, open console and you will see errors, because it websockets connection has failed. We haven't implemented it yet.

websocket connection error

Writing consumers for websockets

Create a file chat/consumers.py

import json

from channels.generic.websocket import WebsocketConsumer


class ChatConsumer(WebsocketConsumer):
    def connect(self):
        self.accept()

    def disconnect(self, close_code):
        pass

    def receive(self, text_data):
        text_data_json = json.loads(text_data)
        message = text_data_json["message"]

        self.send(text_data=json.dumps({"message": message}))

Create a file chat/routing.py

from django.urls import re_path

from . import consumers

websocket_urlpatterns = [
    re_path(r"ws/chat/(?P<room_name>\w+)/$", consumers.ChatConsumer.as_asgi()),
]

Edit the file project/asgi.py

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", "project.settings")
# Initialize Django ASGI application early to ensure the AppRegistry
# is populated before importing code that may import ORM models.
django_asgi_app = get_asgi_application()

from chat.routing import websocket_urlpatterns

application = ProtocolTypeRouter(
    {
        "http": django_asgi_app,
        "websocket": AllowedHostsOriginValidator(
            AuthMiddlewareStack(URLRouter(websocket_urlpatterns))
        ),
    }
)

Configure CHANNELS_LAYERS

Open project/settings.py and add CHANNEL_LAYERS, for example after ASGI_APPLICATION:

ASGI_APPLICATION = "project.asgi.application"
REDIS_URL = env("REDIS_URL")
CHANNEL_LAYERS = {
    "default": {
        "BACKEND": "channels_redis.core.RedisChannelLayer",
        "CONFIG": {
            "hosts": [REDIS_URL],
        },
    },
}

What happens here: REDIS_URL will be populated from REDIS_URL env variable via django-environ library. If the REDIS_URL is not set, the project won't start.

Then we split the REDIS_URL which looks like this (it is specified in our .env file) redis://redis:6379/0 and get redis host and port.

Then we pass it to CHANNELS_LAYERS configuration.

Let's make sure that channel layer is able to talk to Redis.

In docker shell run

python manage.py shell

And in python shell write this:

import channels.layers
channel_layer = channels.layers.get_channel_layer()
from asgiref.sync import async_to_sync
async_to_sync(channel_layer.send)('test_channel', {'type': 'hello'})
async_to_sync(channel_layer.receive)('test_channel')

The output should be {'type': 'hello'} chat layers test

Press CTRL-D to exit Django shell.

Now let's update our file chat/consumers.py:

import json

from asgiref.sync import async_to_sync
from channels.generic.websocket import WebsocketConsumer


class ChatConsumer(WebsocketConsumer):
    def connect(self):
        self.room_name = self.scope["url_route"]["kwargs"]["room_name"]
        self.room_group_name = f"chat_{self.room_name}"

        # Join room group
        async_to_sync(self.channel_layer.group_add)(
            self.room_group_name, self.channel_name
        )

        self.accept()

    def disconnect(self, close_code):
        # Leave room group
        async_to_sync(self.channel_layer.group_discard)(
            self.room_group_name, self.channel_name
        )

    # Receive message from WebSocket
    def receive(self, text_data):
        text_data_json = json.loads(text_data)
        message = text_data_json["message"]

        # Send message to room group
        async_to_sync(self.channel_layer.group_send)(
            self.room_group_name, {"type": "chat.message", "message": message}
        )

    # Receive message from room group
    def chat_message(self, event):
        message = event["message"]

        # Send message to WebSocket
        self.send(text_data=json.dumps({"message": message}))

Make sure all of the files are saved, and go back to our browser where we have our chat room open.

Refresh the page and in Dev Tools console there will be no errors about websocket connection.

Open another tab with the same URL and type something there and send the message. Switch to the second browser tab and you will see that message is displayed.

Our chat works!

Rewriting our consumers to be async

Rewriting channels consumers to be async comes with benefit of better performance, because synchronous require to spin up a new thread to process every request.

Let's not get into differences sync and async code now and just update chat/consumers.py to make it async:

import json

from channels.generic.websocket import AsyncWebsocketConsumer


class ChatConsumer(AsyncWebsocketConsumer):
    async def connect(self):
        self.room_name = self.scope["url_route"]["kwargs"]["room_name"]
        self.room_group_name = f"chat_{self.room_name}"

        # Join room group
        await self.channel_layer.group_add(self.room_group_name, self.channel_name)

        await self.accept()

    async def disconnect(self, close_code):
        # Leave room group
        await self.channel_layer.group_discard(self.room_group_name, self.channel_name)

    # Receive message from WebSocket
    async def receive(self, text_data):
        text_data_json = json.loads(text_data)
        message = text_data_json["message"]

        # Send message to room group
        await self.channel_layer.group_send(
            self.room_group_name, {"type": "chat.message", "message": message}
        )

    # Receive message from room group
    async def chat_message(self, event):
        message = event["message"]

        # Send message to WebSocket
        await self.send(text_data=json.dumps({"message": message}))

Save the file, refresh both of the browser tabs and send some messages in the chat, you should see them appear in both tabs.

Add a run command

In the root of the project create a file run.sh with the following content:

daphne project.asgi:application --port=$PORT --bind 0.0.0.0 -v2

This file will be used to run our web process.

This command does two important parts:

  • it makes daphne listen on all interfaces (0.0.0.0 IP)
  • passes $PORT environment variable to the --port argument, because daphne itself doesn't respect this environment variable.

Commit the code, push our Django Channels to GitHub

We need to add all the files to git repository.

We do that outside of the docker shell. To leave docker shell type ctrl+d.

Leave docker shell

git add .
git commit -m'Initial Commit'

Go to github.com/new and create a new private repository.

In this screenshot repository is set to public, but for your projects, you might want to set it to private.

Create git repository for django channels tutorial chat app

Since the repository is empty, GitHub will offer you a few commands. We need the line git remote add origin Copy this line and run it in your terminal

git add git commit add remote and push to GitHub

We can now see our code is pushed to GitHub. Django Channels Chat Tutorial app on GitHub

Deploying Django Channels Chat App on a Server

Go to Appliku dashboard, create an account if you don't have one already.

Create and add a server for Django Channels App

Add an Ubuntu or a Debian server from a cloud provider of your choice, if you haven't already.

Here are guides for adding servers from different cloud providers:

Create an application from GitHub repository

After you have created a server, go to the "Applications" page and create a new application from GitHub repository.

Create Django channels application from GitHub repository

Create Postgres and Redis databases

On the application overview page find the "Databases" block and click "Add Database".

Create a postgres 17 and redis 7 databases on the same server as the app.

Create databases for Django Channels application

Create Postgres 17 database:

Create Postgres 17 database

Create Redis 7 database: Create redis 7 database

Create processes for Django Channels Application

Go back to application overview page and in the Processes block click "Add Processes" button.

Add processes

Add two processes: web and release.

Be careful: names of the processes are important.

The web process is the one that receives HTTP traffic, if you name it any other way, you app will not open in the browser.

The release process is the command that is called after every deployment. For Django we usually want to at least run the migrate management command.

Processes setup

Click Save and Deploy.

The deployment process will start.

If the deployment process has failed - click on the latest deployment to find the error.

After it is finished, go to application overview page, click Open App and click on the domain name. Open Django Channels application domain

If you see 502 Bad Gateway error when running your Django Channels application, go to Click on the App Logs to see if there are any errors.

502 Bad Gateway Django Channels Application

Django Channels App logs

Scroll down the log window to find the error.

In this example, I have incorrectly named the project python package.

Django Channels App logs error

When you push changes to your code, the deployment will start automatically.

When the errors are fixed, open two windows/tabs of your application and enter the same chat room.

Django Channels Chat app two rooms

image