In this article I will show you how to start a new Django project from scratch and set it up to work in Docker for local development. We'll also cover multi-stage builds, health checks, named volumes, and how to separate dev and production configs with docker-compose.override.yml.

Source code

The source code for this tutorial is available on GitHub You can check your workflow and compare it with your project to identify mistakes and understand what went wrong if this happens.

Requirements

You need Docker Installed on your machine.

For Mac OS and Windows use Docker Desktop. Follow the link, download and install it on your machine.

We will not be using local Python installation for this tutorial, everything will work within the Docker container.

What is Docker and Docker Compose?

Docker is a way to package and run applications in a portable and isolated way.

Docker Compose is a tool for managing multiple containers as a single application.

Why dockerize your Django application?

Dockerizing your Django application means packaging it with all the dependencies into a container or at least Dockerfile. This makes a reproducible environment for running your app which helps running app on different systems, local or production.

Benefits of Dockerizing your Django application

  • Makes your application portable and scalable
  • Runs on any platform (local or cloud)
  • Eliminates installation/compatibility concerns
  • Enables testing across different environments

  • Provides application isolation

  • Separates app from host machine
  • Prevents conflicts with other applications/services

  • Streamlines development workflow

  • Quick environment setup and teardown
  • More efficient development and testing
  • Eliminates repetitive environment configuration
  • Simplifies debugging process
  • Simplifies setup of auxiliary services (Postgres, Redis, RabbitMQ, etc)

Creating a fresh Django Project with Docker and Postgres

Create a folder and git init

Let's begin. Open Terminal and do next steps: - create a folder of your project:

mkdir django-docker-tutorial
  • go to the new folder
cd django-docker-tutorial
  • and now create a new Git repository .git
git init

Create a Dockerfile for Django

The Dockerfile defines your Django application's container image. We'll use a multi-stage build to keep the final image small and production-ready.

In the root of your project create Dockerfile:

# ---- Build Stage ----
FROM python:3.12.8-bullseye AS builder
ENV PIP_NO_CACHE_DIR=off \
    PIP_DISABLE_PIP_VERSION_CHECK=on \
    PYTHONUNBUFFERED=1
WORKDIR /code/
COPY requirements.txt /code/
RUN pip install wheel && pip install -r requirements.txt

# ---- Runtime Stage ----
FROM python:3.12.8-slim-bullseye
SHELL ["/bin/bash", "--login", "-c"]
ARG USER_ID=1000
ARG GROUP_ID=1000
RUN groupadd -g $USER_ID -o app && \
    useradd -m -u $USER_ID -g $GROUP_ID -o -s /bin/bash app
ENV PIP_NO_CACHE_DIR=off \
    PIP_DISABLE_PIP_VERSION_CHECK=on \
    PYTHONUNBUFFERED=1 \
    PYTHONDONTWRITEBYTECODE=1 \
    COLUMNS=80
RUN apt-get update \
    && apt-get install -y --no-install-recommends \
    curl nano gettext \
    && rm -rf /var/lib/apt/lists/*
COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
COPY --from=builder /usr/local/bin /usr/local/bin
WORKDIR /code/
COPY . /code/
USER app

Why Multi-Stage Builds?

Multi-stage builds are a best practice for Django Docker setups. The key benefit: your final image only contains what's needed to run the app — not the build tools, compilers, or cache from installing packages.

  • Stage 1 (builder): installs all Python dependencies
  • Stage 2 (runtime): copies only the installed packages from stage 1, runs as a non-root user

This results in smaller, more secure images. The slim base image alone saves ~200MB compared to the full python:bullseye.

Requirements for Django Project

In the same way 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

docker-compose.yml for Django Project

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

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

services:
  db:
    image: postgres:17
    environment:
      - POSTGRES_USER=tutorial
      - POSTGRES_PASSWORD=tutorial
      - POSTGRES_DB=tutorial
    volumes:
      - postgres_data:/var/lib/postgresql/data
    ports:
      - "5432:5432"
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U tutorial"]
      interval: 10s
      timeout: 5s
      retries: 5
  web:
    <<: *app
    command: python manage.py runserver 0.0.0.0:8000
    ports:
      - "127.0.0.1:8000:8000"

volumes:
  postgres_data:

Two important additions compared to a basic setup:

Health checkdepends_on: condition: service_healthy tells Docker Compose to wait until Postgres is actually ready before starting the web container. Without this, Django may start before the database is accepting connections and throw an error.

Named volumepostgres_data:/var/lib/postgresql/data stores your database data in a named Docker volume instead of the container's filesystem. This means your data persists across docker compose down restarts. Without a named volume, your database is wiped every time the container is recreated.

.env file

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

DATABASE_URL=postgresql://tutorial:tutorial@db:5432/tutorial
DEBUG=True

We need .env file to store environment variables. They contain sensitive information like credentials for DBs and external services, can't be hardcoded and shouldn't ever be committed to version control.

.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
db.sqlite3

These are the files that shouldn't be added to version control. We add here some of the OS specific temporary files, python cache, IDE folders and .env file to prevent it being committed to version control.

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

Now let's start a shell within our web container.

docker compose run web bash

Now you will see a bash prompt from within a container. To leave this shell type exit or press CTRL-D.

Start our Django project:

django-admin startproject project .

Edit Project Settings

Open the file project/settings.py and replace it with this code:

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": [BASE_DIR / "templates",],
        "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/")

Why we have changed it is because the default Django settings file is not flexible, and not suitable for deployment.

We have added some libraries and settings. Let's go over them.

django-environ and environment variables

To avoid hardcoding credentials and other parameters we rely on environment variables.

django-environ library helps us handle environment variables, provide default values for them, casts them to a specific type and optionally reads them from .env file.

Although, since we are using docker compose load environment variables there, it is not that important to read it from the Django code anymore.

Configurations like SECRET_KEY, ALLOWED_HOSTS, DATABASE_URL are environment specific and sensitive so they should never be hardcoded. Also they change between environments and it is much easier to change environment variable than to have to change the code to set them.

Whitenoise library for serving static files with Django

The whitenoise library allows Django to efficiently serve static files. You don't need to upload it to S3 or setup nginx to serve static files anymore.

To enable whitenoise library we add it to the MIDDLEWARE and add two settings:

WHITENOISE_USE_FINDERS = True
WHITENOISE_AUTOREFRESH = DEBUG

Also, we configure STATIC_* settings:

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

SECURE_PROXY_SSL_HEADER

This setting is needed for Django to understand if it is running behind a secure proxy.

Reverse proxy must set the header to signify it is served securely. Appliku does that in nginx configuration when the app is deployed.

SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")

Logging

We need Django to produce logs to be able to debug potential issues.

LOGGING = {
    "version": 1,
    "disable_existing_loggers": False,
    "handlers": {"console": {"class": "logging.StreamHandler"}},
    "loggers": {"": {"handlers": ["console"], "level": "DEBUG"}},
}

Media

We set configuration for serving media from local volume.

Read more about volumes in Appliku

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

Alternatively, you might want to use S3 for Media file uploads in Django

Databases

Here is how Django gets DATABASES setting. django-environ will read the DATABASE_URL environment variable and set appropriate DB type and credentials from the URL string.

DATABASES = {
    "default": env.db(default="sqlite:///db.sqlite3"),
}

Apply migrations

Let's apply Django migrations and make sure our project and database connection works correctly.

In the Docker bash shell run this:

python manage.py migrate

image

By the way if you closed the shell or you want to run one command in Docker without launching shell you can do it another way:

docker compose run web python manage.py migrate

Start containers

This command will start all services defined in docker-compose.yml.

Run this command outside docker compose shell. To leave the container shell press CTRL-D.

docker compose up -d

The output of successful execution of this command should look this way:

image

Check logs of Django Project with docker compose logs

Now to make sure our services are actually working fine let's see logs.

docker compose logs -f

You will see logs for all services running. To stop following logs press CTRL-C.

To show logs for specific app you can specify process(es) to follow:

docker compose logs -f web

Here is the output for docker compose logs -f web:

image

Now we can open our browser and see the Django start page.

Note, if you are on Windows, 0.0.0.0 URL will not work, replace it with http://127.0.0.1:8000

image

You are seeing this page only because the DEBUG variable is on. If we set it to off, the page will be just 404 Not found.

Finally, create a root templates folder and an empty .gitkeep to be able to add that directory to git.

mkdir -p templates
touch templates/.gitkeep

Let's start a Django application and make a simple view for the main page.

Separating Dev and Prod with docker-compose.override.yml

Docker Compose automatically merges docker-compose.override.yml on top of docker-compose.yml when you run docker compose up. This lets you keep dev-specific settings out of your main config file without needing separate docker compose -f flags.

Create docker-compose.override.yml for local development:

# docker-compose.override.yml
# Applied automatically during local development (not committed to prod)
services:
  web:
    environment:
      - DEBUG=True
    volumes:
      - .:/code
    command: python manage.py runserver 0.0.0.0:8000

For production, create docker-compose.prod.yml (applied explicitly with -f):

# docker-compose.prod.yml
services:
  web:
    restart: always
    command: gunicorn project.wsgi:application --bind 0.0.0.0:8000 --workers 2 --log-file -
    environment:
      - DEBUG=False

To run in production mode:

docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d

Add docker-compose.override.yml to .gitignore if team members need different local settings. Or commit it as the default dev override — it won't be applied in production unless explicitly specified.

Start an application

If you have closed the container shell, start it again with docker compose run web bash.

Within the container shell start out app:

python manage.py startapp mainapp

Add our app to INSTALLED_APPS in project/settings.py.

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

Edit the file mainapp/views.py

from django.views.generic import TemplateView


class MainPage(TemplateView):
    template_name = "mainapp/index.html"

This will render our template for the main page.

Create a file mainapp/urls.py

from django.urls import path
from . import views

urlpatterns = [
    path('', views.MainPage.as_view(), name='main_page'),
]

Now edit our root URLConf in project/urls.py to include mainapp/urls.py in there.

from django.contrib import admin
from django.urls import path, include

urlpatterns = [
    path('', include('mainapp.urls')),
    path('admin/', admin.site.urls),
]

ALTERNATIVELY you could import mainapp views directly from the root URLconf this way

from django.contrib import admin
from django.urls import path, include
from mainapp import views

urlpatterns = [
    path('', views.MainPage.as_view(), name='main_page'),
    path('admin/', admin.site.urls),
]

I am a big fan of single app projects instead of having multiple apps per project. (I will write an article about it later and will add a link from here).

Now we need to create our template.

We have our templates folder in the root of the project. Templates from it will be picked up because in the TEMPLATES setting in project/settings.py we have added that directory.

I like having templates in one place, just like having all URLs there to reduce the number of places where similar things can reside.

Also, having all templates in root directory directory over per-app templates folder, allows us overriding templates of 3rd party apps like the admin one.

Create a file templates/mainapp/index.html

<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Django Docker + Postgres Tutorial</title>
    <style>
        body {
            display: flex;
            justify-content: center;
            align-items: center;
            height: 100vh;
            margin: 0;
            background-color: #dedede;
        }
        h1 {
            color: purple;
        }
    </style>
</head>
<body>
    <h1>Welcome to Django Docker + Postgres Tutorial</h1>
</body>
</html>

Back to the browser at http://127.0.0.1:8000

Our simple welcome page on 127.0.0.1

Commit and push to GitHub

Now let's commit everything to version control

git add .
git commit -m'Initial Commit'

Go to GitHub create a new repository.

When the repository is created copy the line "git remote add" and run it. This line for copying you can find on the second step of creating a new repository in "Quick start..." section

then

git push -u origin master

Here I need to say you should be careful because you may get the answer like this:

error: src refspec master does not match any
error: failed to push some refs to 'https://github.com/.../django-docker-tutorial.git'

It is because you branch may be called in another way not 'master'. To find it out do this:

git status

You may see this:

On branch main
nothing to commit, working tree clean

So to push you should do this command:

git push -u origin main

Deploying your app

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

Create and add a server for Django 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 application from GitHub Repository

Create a Postgres Database

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

image

Create a Postgres 17 database on the same server as the app.

image

Click on the Application name at the bottom of the screen to go to the application dashboard page.

Click on Add Processses.

image

Create two processes:

web: gunicorn project.wsgi --log-file -
release: python manage.py migrate

Names of the processes are important.

The web is the process that will receive HTTP traffic

The release is the processes that is executed after each successful deployment.

image

You can also edit environment variables on the "Environment variables" tab.

But now click "Save and deploy" button, then return to the application dashboard page.

image

If deployment fails click on the deployment number to see the logs of deployments and find the error.

When the deployment finishes, click on the Open App button and then click on the domain name.

image

image

Our application is now deployed and has TLS certificate, issued by Let's Encrypt.

You can also add your own custom domain in the application settings -> Custom domains.

Note: If the app has been deployed but you get 502 Bad Gateway error or any other error you can go to App Logs to see what can be the problem with the running app.

image

image