Beyond the Basics: 10 Battle-Tested Tips to Optimize Django in Docker
Is your Django Docker image 5GB and painfully slow to build? Here are 10 production-learned tips to shrink, speed up, and secure your containers.
You want to scale your application, and someone tosses out the classic advice: “Just containerize it, it will be easier.”
So you open Google, search How to Dockerize a Django project, and PAM—it is done in minutes.
But then you look closer. Why is the build slow? Why does it starting up slower than me on a weekend? And, more importantly, why on earth is my production image 5 or 6 GB?
In this post, I am sharing 10 optimizations I learned the hard way to build fast, lightweight, and secure Django images. And no, this is not just going to be the generic “use Alpine and multi-stage builds” lecture. (Though we are going to use them, because they work, but let’s look at what else you should be doing).
1. Swap pip for uv
We all know uv by now—it is pip’s incredibly fast cousin. Without caching, uv installs dependencies 8 to 10 times faster than standard tools. With a warm cache, we are looking at speeds up to 115 times faster.
But inside Docker, you need to configure it carefully to avoid these 4 time bombs:
- .pyc files being compiled on startup, this leads to slower startup times.
- Soft-linking libraries in a multi-stage build leads to nowhere, you won’t find the libraries if you move between images.
uvis used to managingpythonitself we need to stop that.
Adding these environment variables to your Dockerfile are the main solutions:
# 1) Pre-compile .pyc files so Python doesn't have to compile them on first import
ENV UV_COMPILE_BYTECODE=1
# 2) Prevent hard-link warnings across different filesystems inside the container
ENV UV_LINK_MODE=copy
# 3) Stop uv from downloading its own Python interpreters—use the base image's Python
ENV UV_NO_MANAGED_PYTHON=1
ENV UV_PYTHON_DOWNLOADS=never
The tricky part to watch out for: By default, uv uses symlinks for speed. However, symlinks created in a builder stage will point to empty space once you copy files over to a lean production stage. This results in highly confusing “Cannot import Django” errors at runtime. Always set UV_LINK_MODE=copy to bundle the actual files.
2. Separate Your Migrations Into Their Own Service
Never package your database migration commands inside the same container that boots up your web app. If you scale your web container to five instances, you run the risk of five containers trying to run migrations simultaneously against the same database (You really don’t want to mess this up, especially in Prod).
Instead, define a dedicated migration service in your docker-compose.yml that exits immediately after completion:
services:
migrate:
build: .
depends_on:
postgres:
condition: service_healthy
command: uv run manage.py migrate
This setup ensures database schemas are updated safely before your web service even attempts to start.
3. Make depends_on Smart with Health Checks
A standard depends_on only checks if the database container has started, not if it is actually ready to receive connections. If Django attempts to run migrations while Postgres is still initializing, your app will crash.
Pair depends_on with a real health check instead:
services:
web:
build: .
depends_on:
postgres:
condition: service_healthy
postgres:
image: postgres:16-alpine
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
Now, Django waits patiently until Postgres is fully listening before starting its own boot sequence.
4. Write True Application Health Checks
An empty HTTP 200 “OK” view does not cut it for production monitoring, especially if it says 200 while your system is down. If your database or cache drops, a dumb health check will still report that the container is healthy.
Create a dedicated health view in Django that tests your actual architecture:
# views.py
from django.db import connections
from django.http import JsonResponse
from django.views import View
from django_redis import get_redis_connection
class DeepHealthCheckView(View):
def get(self, request):
try:
# Verify database connection
for conn in connections.all():
conn.cursor()
# Verify Redis cache
redis_client = get_redis_connection("default")
redis_client.ping()
return JsonResponse({"status": "healthy"}, status=200)
except Exception as e:
return JsonResponse({"status": "unhealthy", "reason": str(e)}, status=503)
Point Docker’s internal HEALTHCHECK directive directly at this endpoint to let orchestrators automatically restart misbehaving containers.
One Trap I mess up is forgetting to add a timeout so the conn.cursor() keeps trying and hanging for nearly 60s, watch out for this, our your healthCheck endpoint will feel like it’s dead.
5. Bake collectstatic Directly Into the Build
Running collectstatic when your container boots up adds a frustrating delay to your deployment pipeline. Instead, run it as a RUN step in your Dockerfile during the build phase. This ensures your static assets are version-locked and ready to serve immediately.
The catch: Django requires a valid SECRET_KEY (and sometimes a database configuration) just to parse your settings files during asset collection. Pass a dummy secret key during the build step to get around this restriction:
# Run collectstatic without requiring a live database connection
RUN SECRET_KEY=dummy-build-key-value-12345 uv run manage.py collectstatic --noinput
6. Shave Off 50% Image Weight with Multi-Stage Builds
Here is why your image is massive: to compile packages like cryptography or psycopg2, your image needs building blocks like gcc, g++, make and musl-dev. These tools easily add 250MB+ of bloat to your runtime environment.
With a multi-stage build, you use one heavy stage to compile your dependencies, then copy only the compiled results into a pristine, final container:
# Stage 1: Build dependencies
FROM python:3.12-alpine AS builder
RUN apk add --no-cache build-base libffi-dev
# ... install packages with uv ...
# Stage 2: Final lightweight runner
FROM python:3.12-alpine
# Copy only the compiled site-packages and static files
COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
# ... run Django ...
Well we did use alipine in the end
7. Organize Multiple Environments Using Compose Profiles
Instead of maintaining a messy stack of docker-compose.yml, docker-compose.dev.yml, and docker-compose.prod.yml files, use Compose Profiles to manage everything inside a single file.
You can tag debug tools like pgadmin or flower with a specific profile:
services:
pgadmin:
image: dpage/pgadmin4
profiles:
- debug
ports:
- "8080:80"
By default, running docker compose up will ignore these debug services. If you need to troubleshoot, you can target them specifically:
docker compose --profile debug up
8. Prevent Your Logs From Eating Your Hard Drive
If left untamed, Docker will store container stdout logs in local JSON files indefinitely. Over time, your long-running production containers will quietly consume your host’s disk space until the server crashes.
Set a strict ceiling directly in your Compose service configurations:
services:
web:
build: .
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
Note: You still want to configure Django’s internal file-logging rotation separately if you write logs to disk inside the app.
9. Use Structured JSON Logs in Production
Plain-text logs are fine for your local terminal, but they are incredibly difficult to parse when you ship them to central aggregators like Datadog, ELK, or Sentry.
Configure Django’s LOGGING dictionary to toggle formats based on your environment using python-json-logger:
import os
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"json": {
"()": "pythonjsonlogger.jsonlogger.JsonFormatter",
"format": "%(asctime)s %(levelname)s %(name)s %(message)s",
},
"simple": {
"format": "%(levelname)s %(message)s",
},
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"formatter": "json" if os.getenv("DJANGO_ENV") == "production" else "simple",
},
},
"root": {
"handlers": ["console"],
"level": "INFO",
},
}
10. Mount Sensitive Data Securely Using Secrets
Passing API keys and passwords as system environment variables is risky. They can easily leak through process viewers, crash dumps, or build logs.
Docker Compose provides a cleaner alternative: Docker Secrets. These mount keys directly as read-only files inside the container at /run/secrets/.
In Compose, define them like this:
services:
web:
build: .
secrets:
- db_password
secrets:
db_password:
file: ./secrets/db_password.txt
Inside your settings.py, you can read the secret safely:
import os
def get_secret(key, default=None):
# Check if a file-based secret path exists
secret_file_env = f"{key}_FILE"
if secret_file_env in os.environ:
secret_path = os.environ[secret_file_env]
if os.path.exists(secret_path):
with open(secret_path, "r") as f:
return f.read().strip()
return os.environ.get(key, default)
DATABASES = {
"default": {
# ...
"PASSWORD": get_secret("DB_PASSWORD"),
}
}
Bonus: Keep Local Secrets Clean with docker-compose.override.yml
Whenever you run docker compose up, Docker automatically searches for a file named docker-compose.override.yml in the same directory and merges its settings over your primary configuration.
This is the perfect place to put your local developer secrets, custom volume mounts, and debugging port configurations. Add docker-compose.override.yml to your .gitignore file so it never gets committed to your repository. It is a much cleaner approach than relying solely on .env files because you can override commands, ports, and volumes on the fly.
Investing a little bit of time into your Docker setup today will prevent those frustrating, late-night deployment headaches down the road. Try picking just one of these techniques to implement this week—your team (and your future self) will thank you (and hopefully me too).
Bye.