When a product has a Django backend and a Flutter mobile app, the backend quickly becomes the part everyone depends on. The app team needs a working API locally, testers need a stable staging server, and production needs to behave exactly like what was tested. Docker is the simplest way we know to make those three environments match.
This tutorial describes the setup pattern we use on projects like RaptureTech, a dairy platform with Django APIs, a Flutter app and Dockerized delivery. The example files are simplified and sanitised, but the structure is the one we actually use.
What we are containerising (and what we are not)
The Flutter app itself does not run in Docker in this setup; it is built and shipped to the app stores the normal way. What goes in containers is everything the app talks to:
- web: the Django + Django REST Framework API, served by Gunicorn
- db: PostgreSQL
- worker (optional): a background task runner for notifications, reports or imports
- redis (optional): a broker/cache for the worker
Project layout
project/
├── backend/
│ ├── config/ # Django settings, urls, wsgi
│ ├── apps/ # Django apps (accounts, orders, ...)
│ ├── requirements.txt
│ ├── Dockerfile
│ └── entrypoint.sh
├── mobile/ # Flutter app (not containerised)
├── docker-compose.yml # local development
├── docker-compose.prod.yml
└── .env.example # documented variables, no real secrets
A minimal Dockerfile for Django
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends libpq5 \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN python manage.py collectstatic --noinput
CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "3"]
Two details matter here. Copying requirements.txt before the rest of the code lets Docker cache the dependency layer, so rebuilds after code changes take seconds instead of minutes. And a slim base image keeps the image small enough to push and pull quickly on modest connections.
docker-compose for local development
services:
db:
image: postgres:16
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"]
interval: 5s
retries: 10
web:
build: ./backend
command: python manage.py runserver 0.0.0.0:8000
env_file: .env
volumes:
- ./backend:/app
ports:
- "8000:8000"
depends_on:
db:
condition: service_healthy
volumes:
pgdata:
With this, a new developer runs docker compose up and has the API and database running in minutes. The Flutter developer points the app at the machine's local network IP on port 8000 (not localhost, which on an emulator or phone means the device itself).
From the Android emulator, your computer is reachable at 10.0.2.2. A physical phone needs the computer's LAN IP, and Django's ALLOWED_HOSTS must include it.
Environment variables and secrets
Everything that differs between environments goes in environment variables, never in code:
# .env.example: copy to .env and fill in; never commit .env
DJANGO_SECRET_KEY=change-me
DJANGO_DEBUG=0
DJANGO_ALLOWED_HOSTS=api.example.com
DATABASE_URL=postgres://user:password@db:5432/appdb
CORS_ALLOWED_ORIGINS=https://admin.example.com
Commit .env.example with every variable documented, add .env to .gitignore, and store production values in your host's secret manager or environment settings. A leaked secret key or database password in a Git repository is one of the most common and most avoidable incidents in small projects.
Migrations on deploy
Run migrations as an explicit step of every deployment, before the new version starts serving traffic. A small entrypoint script is enough:
#!/bin/sh
set -e
python manage.py migrate --noinput
exec "$@"
If you run more than one web container, run migrations once as a separate one-off task instead, so two containers do not try to migrate at the same time.
Local vs production: what changes
| Concern | Local | Production |
|---|---|---|
| Server | runserver with code mounted as a volume | Gunicorn behind a reverse proxy / load balancer |
| Debug | On | Always off |
| Database | Postgres container with a local volume | Managed database (e.g. Amazon RDS) with automated backups |
| Static & media files | Served by Django | Object storage / CDN, or WhiteNoise for static files |
| HTTPS | Not needed | Required: mobile apps should only talk to HTTPS APIs |
One deliberate choice: in production we prefer a managed database over a Postgres container. Containers are great for stateless app servers; your customers' data deserves automated backups, point-in-time recovery and someone else patching the database server.
Common mistakes to avoid
- Storing uploaded files inside the container. They disappear on the next deploy. Use a volume or, better, object storage.
- Using the
latesttag for base images. Builds change underneath you. Pin versions. - No health check. The web container starts before the database is ready and crashes on boot.
- Baking secrets into the image. Anyone who can pull the image can read them.
Where to go from here
This setup is intentionally small. It will carry most early-stage products a long way before you need orchestration. For the API design side of serving a mobile app, read designing REST APIs a mobile app will not fight with. If you are still choosing the mobile framework, see Flutter vs React Native.