Docker & DevOps

Containerise a Python web app with Docker and Docker Compose

Write a production Dockerfile for a Flask or Django app, run it with Gunicorn, PostgreSQL and Redis in Docker Compose, and handle migrations, static files and logs.

IntermediateLinux VPS3 min read

Packaging your app in a container means the server runs exactly what you tested, with the same Python version and libraries. This guide shows how to dockerize a Python app (Flask or Django) and run it with PostgreSQL and Redis using Docker Compose on a VPS.

Before you start

  • Docker Engine with the Compose plugin on your VPS and, ideally, on your computer for testing.
  • An app with a requirements.txt that includes gunicorn (and psycopg[binary] for PostgreSQL).
  • Settings read from environment variables (database URL, secret key, debug flag).

Write the Dockerfile

Create Dockerfile in the project root:

FROM python:3.12-slim

ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

RUN useradd -m appuser
USER appuser

EXPOSE 8000
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "3", "myproject.wsgi:application"]
  • Copying requirements.txt first lets Docker cache the dependency layer, so code changes rebuild in seconds.
  • PYTHONUNBUFFERED sends logs straight to docker logs.
  • Running as a non-root user limits the damage of a compromised app.
  • For Flask, use app:app instead of the Django WSGI path.

Add a .dockerignore so secrets and junk stay out of the image:

.git
.env
__pycache__/
*.pyc
venv/
media/

Write docker-compose.yml

services:
  web:
    build: .
    restart: unless-stopped
    env_file: .env
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_started
    ports:
      - "127.0.0.1:8000:8000"
    volumes:
      - static:/app/staticfiles
      - media:/app/media

  db:
    image: postgres:16-alpine
    restart: unless-stopped
    environment:
      POSTGRES_DB: app
      POSTGRES_USER: app
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d app"]
      interval: 5s
      retries: 10

  redis:
    image: redis:7-alpine
    restart: unless-stopped

volumes:
  pgdata:
  static:
  media:

The matching .env (never committed):

POSTGRES_PASSWORD=change-me
DATABASE_URL=postgresql://app:change-me@db:5432/app
REDIS_URL=redis://redis:6379/0
SECRET_KEY=long-random-string
DEBUG=0
ALLOWED_HOSTS=example.com

Services reach each other by name (db, redis) on Compose's private network; neither is published to the internet.

Build, migrate and start

docker compose up -d --build
docker compose exec web python manage.py migrate
docker compose exec web python manage.py collectstatic --noinput

For Flask with Flask-Migrate, run flask db upgrade instead. Create a Django admin user with docker compose exec web python manage.py createsuperuser.

Serve it over HTTPS

Put Nginx (on the host or as another container) in front of 127.0.0.1:8000 with Certbot for SSL. Serve static files directly from Nginx or with WhiteNoise inside the app. Pass X-Forwarded-Proto and, in Django, set SECURE_PROXY_SSL_HEADER so the app knows requests are HTTPS.

Deploy updates

git pull && docker compose up -d --build && docker compose exec web python manage.py migrate

Compose only recreates containers whose image or config changed. Clean old images occasionally with docker image prune.

Check that it works

  • docker compose ps: all services running, db healthy.
  • docker compose logs -f web: Gunicorn booted workers without errors.
  • curl -I http://127.0.0.1:8000 returns 200 or a redirect.

Troubleshooting

  • could not translate host name "db": the app runs outside Compose or uses a different network. Run it as a Compose service.
  • Connection refused to db on startup: the app started before PostgreSQL was ready; use the health check condition shown above.
  • Static files 404: run collectstatic and make sure Nginx or WhiteNoise serves STATIC_ROOT.
  • Permission denied writing media: the volume is owned by root while the app runs as appuser. Fix ownership once with docker compose exec -u root web chown -R appuser /app/media.

Python Docker FAQ

Should I use Alpine for Python images?

Usually not: many Python wheels are built for glibc, so slim images install faster and avoid compile problems.

Do I still need Gunicorn inside Docker?

Yes. Docker only runs the process; Gunicorn is the production server that handles concurrent requests.

How do I back up the database?

Run docker compose exec -T db pg_dump -U app -Fc app > app.dump on a schedule and copy the file off the server.

Next steps

Was this guide helpful?

Still stuck?

Ask MdmHost AI for a quick answer, or open a ticket and our team will take a look. We are here 24/7.