Home · About · Services · Industries · Projects · Products · Process · Blog · FAQ · Contact
Containerising a legacy Django app: a step-by-step guide How we containerise a legacy Django app: freeze dependencies, move settings to env vars, build a multi-stage image, handle static and media, then cut over.
The app was written in 2017. It runs on one VPS under a virtualenv someone built by hand, served by Gunicorn behind NGINX, with a crontab nobody has read in years. Nobody wants to touch the server because nobody is sure what is on it. Containerising it is usually the first step towards fixing that, and it is less risky than it sounds if you do it in the right order.
In short: freeze the dependencies the live server uses, move every setting into environment variables, build a multi-stage image that runs Gunicorn as a non-root user, serve static files from the image, keep media and the database outside the container, run migrations as a separate step, and cut over only after a full rehearsal against a copy of production data.
This guide covers the Django-specific part of that work. For the server side of running the result (health checks, log rotation, networks, backups), our post on Docker Compose in production has the checklist we use.
Step 1: inventory what the server actually runs Legacy apps rarely match their repository. Before writing a Dockerfile, we log in to the live server and write down what is really there.
The Python version (python --version inside the virtualenv) and the Django version. Installed packages, from pip freeze in the live virtualenv, not from a requirements file that may be years out of date. System libraries the packages need: libpq for psycopg2, the MySQL client library for mysqlclient, image libraries for Pillow, wkhtmltopdf or LibreOffice if the app makes PDFs. Every crontab entry, systemd unit and supervisor program that touches the app, including Celery workers and beat. Where uploaded media lives on disk and how large it is. Any file paths, hostnames or credentials hard-coded in settings.py or local_settings.py. Commit the frozen list as requirements.txt. It becomes the contract for the first image. The only additions at this stage are what the container needs: gunicorn, whitenoise and dj-database-url, pinned to versions that support your Django release. Upgrading anything else comes later, as a separate change you can test on its own.
Check the Python version against support dates. Python 3.9 reached end of life in October 2025, and the official python images stop receiving updates once a version is end of life. If the app is on Django 3.2 or older, it cannot run on current Python, so containerise it on the version it runs today, then plan the Django upgrade (5.2 is the current LTS) as the next project. Do not try both at once.
Step 2: move configuration into the environment A container image should be identical in staging and production. Only the environment changes. That means settings.py reads everything sensitive or environment-specific from variables and fails loudly when a required one is missing.
import os
import dj_database_url
SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]
DEBUG = os.environ.get("DJANGO_DEBUG", "0") == "1"
ALLOWED_HOSTS = os.environ.get("DJANGO_ALLOWED_HOSTS", "").split(",")
CSRF_TRUSTED_ORIGINS = os.environ.get("DJANGO_CSRF_ORIGINS", "").split(",")
DATABASES = {"default": dj_database_url.config(conn_max_age=60)}
# TLS ends at the reverse proxy, so trust its header
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
LOGGING = {
"version": 1,
"handlers": {"console": {"class": "logging.StreamHandler"}},
"root": {"handlers": ["console"], "level": "INFO"},
}Two details catch people. From Django 4.0, CSRF_TRUSTED_ORIGINS entries must include the scheme, so the value is https://app.example.com, not app.example.com. And logs must go to stdout and stderr, not to files inside the container, or they disappear when the container is replaced. Remove the local_settings.py import pattern entirely once its values are in variables.
Step 3: write a multi-stage Dockerfile We build wheels in one stage, where compilers are available, and copy only the results into a slim runtime stage. The final image carries no compiler, no build headers and no pip cache.
# syntax=docker/dockerfile:1
FROM python:3.12-slim-trixie AS build
RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential libpq-dev \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip wheel --no-cache-dir --wheel-dir /wheels -r requirements.txt
FROM python:3.12-slim-trixie
ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1
RUN apt-get update \
&& apt-get install -y --no-install-recommends libpq5 \
&& rm -rf /var/lib/apt/lists/* \
&& useradd --create-home --uid 1000 app
COPY --from=build /wheels /wheels
RUN pip install --no-cache-dir --no-index /wheels/* && rm -rf /wheels
WORKDIR /app
COPY --chown=app:app . .
RUN DJANGO_SECRET_KEY=build-only python manage.py collectstatic --noinput
USER app
EXPOSE 8000
CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000", \
"--workers", "3", "--timeout", "60", "--access-logfile", "-"]Replace config.wsgi with your project's WSGI module and the Python tag with the version from step 1 (if no trixie tag exists for an older Python, use the bookworm variant). The dummy secret key exists only so settings import during collectstatic; it never reaches a running container. Add a .dockerignore that excludes .git, the old virtualenv, __pycache__, media, any .env file and local SQLite databases, or they end up in the image.
Start with three Gunicorn workers per container and adjust after watching memory. Gunicorn kills a worker that takes longer than its timeout (30 seconds by default), and legacy apps often have slow report or export views. Measure real request times first, for example by adding $request_time to the NGINX log format for a week, then set the timeout just above the slowest legitimate request.
Step 4: static files, media and background jobs Static files belong to the image. WhiteNoise lets Gunicorn serve them efficiently, so the reverse proxy does not need a shared volume. Add whitenoise.middleware.WhiteNoiseMiddleware directly after SecurityMiddleware and configure storage. On Django 4.2 and later use the STORAGES setting below; STATICFILES_STORAGE was removed in Django 5.1. On older releases, set STATICFILES_STORAGE to the same WhiteNoise class instead.
STATIC_ROOT = BASE_DIR / "staticfiles"
STORAGES = {
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
"staticfiles": {
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
},
}Expect the manifest storage to fail the build on older projects. It refuses to finish if a CSS file references an image that does not exist, and legacy stylesheets often do. Fix the references, or use CompressedStaticFilesStorage until you have.
Media is different. User uploads must survive a container being rebuilt, so they go on a named volume or, better, in object storage through django-storages. S3, Azure Blob and a self-hosted MinIO all work. Moving media to object storage also means a second container can run without sharing a disk.
Background work uses the same image with a different command. A Celery worker becomes a second service running celery -A config worker, and beat becomes a third. Old crontab entries that call manage.py become scheduled one-off runs of the image, from the host scheduler or your platform's cron feature, rather than cron installed inside the web container.
Step 5: migrations and the existing database Do not run migrate in the container's start command. If two containers start together, both try to migrate. Run it as an explicit deploy step instead.
docker compose run --rm web python manage.py showmigrations
docker compose run --rm web python manage.py makemigrations --check --dry-run
docker compose run --rm web python manage.py migrate --noinput
docker compose run --rm web python manage.py check --deployOn a legacy database, showmigrations tells you whether the recorded migration history matches the code. makemigrations --check exits with an error if models have drifted from the migrations, which is common when someone edited the schema by hand. Resolve both before cutover, against a restored copy of production, never the live database. check --deploy then lists the security settings still missing.
Step 6: rehearse, then cut over We run the containerised app on staging against a fresh restore of production data and real media for at least a few days. People click through the paths that matter: login, uploads, reports, emails, scheduled jobs. Then the cutover itself is short.
Lower the DNS TTL a day ahead if the app is moving to a new server. Put the old app into maintenance mode or read-only, and stop its cron jobs and workers. Take a final database dump and media sync, and restore both on the new side. Run the migration checks, start the containers, and test the critical paths. Switch DNS or the proxy upstream, and keep the old server untouched for a week as the rollback. Checklist before you call it done The image builds in CI from a clean checkout and is tagged with the commit SHA. No secrets in the image, the repository or docker inspect output you would share. Settings fail on startup if a required variable is missing. Static files are served from the image; media is outside the container and backed up. Workers, beat and former cron jobs run from the same image. Migrations are a deploy step, and makemigrations --check is clean. A database restore and a container rollback have both been tested. If the inventory in step 1 turns up surprises, such as a Python 2 codebase, undocumented native dependencies or a schema with no matching migrations, budget for that work before the container work. Containerising is the straightforward part. Untangling what the old server was quietly doing is where the time goes, and it is worth getting a second pair of eyes on it before the cutover date is fixed.
Infigini IT Services · 11 Kanwali Road, Dehradun 248001, India · info@infigini.com · +91 95684 87789