Run Django & WordPress Together with Docker Compose

Blog / Server Management · January 20, 2018 · Updated June 10, 2026 · 10 min read
Run Django & WordPress Together with Docker Compose

To run a Django app and a WordPress blog together on one host, give each its own container and put an nginx reverse proxy in front to route requests by URL. With Docker Compose v2 you declare five services in a single compose.yamldjango (Gunicorn), wordpress (the official PHP 8.3 image), nginx (the router), db (PostgreSQL for Django) and wp-db (MariaDB for WordPress) — wire them with a shared network, named volumes and an .env file, then bring the whole stack up with one command: docker compose up -d. nginx sends / to Gunicorn and /blog/ to WordPress, so both apps share the same domain without ever colliding.

This is the clean, reproducible alternative to hand-installing two apps on a single VM. If you instead want the classic single-box approach, see Django and WordPress on the same domain on one EC2 instance — this article containerizes that same idea.

Key takeaways

  • Use Docker Compose v2 (docker compose, the plugin) — not the retired docker-compose v1 hyphen binary. Modern Compose files need no version: key.
  • One container per concern: django (Gunicorn), wordpress (wordpress:php8.3-apache), nginx, db (postgres), wp-db (mariadb).
  • nginx is the router/edge: proxy_pass to django:8000; route /blog/ to the wordpress container. Terminate TLS at nginx (Let’s Encrypt/certbot) or let Caddy/Traefik auto-provision certificates.
  • Make startup deterministic with healthcheck blocks plus depends_on: condition: service_healthy so Django waits for Postgres and WordPress waits for MariaDB.
  • Persist data in named volumes and keep secrets in an .env file that you never commit.
  • Containerized split beats co-hosting two apps on one VM for reproducibility; graduate to Swarm or Kubernetes only when you need multi-node scaling.

Why run Django and WordPress in separate containers?

Django and WordPress have nothing in common at runtime: different languages (Python vs PHP), different databases (PostgreSQL vs MySQL/MariaDB), different process managers (Gunicorn vs Apache/PHP-FPM). Cramming both onto one VM means their dependencies fight over the same OS — a PHP or system-library upgrade for WordPress can quietly break your Django app.

Containers run each service in an isolated environment, so a change inside one container can’t disturb the host or the other container. Compose makes that isolation declarative and repeatable: the exact same compose.yaml runs identically on a laptop, a staging box, or production.

Approach Isolation Reproducibility Ops overhead Best for
Compose multi-container (this guide) Strong — separate images, deps, DBs High — one declarative file Low–medium One host running both apps cleanly
Single-VM co-host Weak — shared OS & libraries Low — manual, snowflake server Low to set up, high to maintain Quick legacy setups, tight budgets
Fully separate hosts/services Strongest — separate machines High (with IaC) Highest — more infra to run High traffic, independent scaling/teams

Already have a Dockerfile for the Python side? See how to Dockerize a Django project for the container build details this guide reuses.

What does the architecture look like?

A single Compose project defines the whole stack on one Docker network. Requests flow browser → nginx → the right container:

  • nginx — the only service that publishes ports (80/443). It reverse-proxies / to Django and /blog/ to WordPress, serves Django’s static/media files, and terminates TLS.
  • django — your Django project served by Gunicorn on port 8000 (internal only).
  • wordpress — the official wordpress:php8.3-apache image listening on port 80 (internal only).
  • dbPostgreSQL for Django, data in a named volume.
  • wp-dbMariaDB for WordPress, data in its own named volume.

Containers talk to each other by service name (db, wp-db, django, wordpress) thanks to Compose’s built-in DNS on the shared network — no IP addresses or host port forwarding required between them.

What changed with Docker Compose v2?

Two modernizations matter before you copy any 2018-era tutorial:

  1. docker compose (space), not docker-compose (hyphen). Compose is now a plugin bundled with Docker Engine. The standalone v1 Python binary reached end of life and is no longer maintained.
  2. No version: key. The top-level version: "3.8" line is obsolete and ignored by the Compose Specification — drop it. Start the file straight at services:.

Check your tooling first:

# Confirm you're on Compose v2 (the plugin) — expect "Docker Compose version v2.x"
docker compose version

# Install Docker Engine + Compose plugin on a fresh host
curl -fsSL https://get.docker.com | sh

How should the project be laid out?

A clean directory tree keeps Compose, the Django build context, and the nginx config separate:

django-wordpress-stack/
├─ compose.yaml
├─ .env                 # secrets — never commit this
├─ django/
│  ├─ Dockerfile
│  ├─ requirements.txt
│  └─ mysite/           # your Django project (manage.py, mysite/wsgi.py, ...)
└─ nginx/
   └─ default.conf

Where do credentials live?

Keep every secret in an .env file at the project root. Compose loads it automatically; add it to both .gitignore and .dockerignore so it never lands in git or an image layer.

# .env  (do NOT commit)

# --- Django ---
DJANGO_SECRET_KEY=replace-with-a-long-random-string
DJANGO_DEBUG=0
DJANGO_ALLOWED_HOSTS=example.com,www.example.com
DATABASE_URL=postgres://django:django-secret@db:5432/django

# --- Django's Postgres ---
DJANGO_DB_NAME=django
DJANGO_DB_USER=django
DJANGO_DB_PASSWORD=django-secret

# --- WordPress's MariaDB ---
WP_DB_NAME=wordpress
WP_DB_USER=wordpress
WP_DB_PASSWORD=wordpress-secret
WP_DB_ROOT_PASSWORD=mariadb-root-secret

How do you write the compose.yaml?

This is the heart of the setup. Note there is no version: key, every stateful service has a healthcheck, and depends_on uses condition: service_healthy so nothing starts before its database is genuinely ready (not merely "container created").

services:
  db:                                   # PostgreSQL for Django
    image: postgres:17
    restart: unless-stopped
    environment:
      POSTGRES_DB: ${DJANGO_DB_NAME}
      POSTGRES_USER: ${DJANGO_DB_USER}
      POSTGRES_PASSWORD: ${DJANGO_DB_PASSWORD}
    volumes:
      - django_db:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${DJANGO_DB_USER} -d ${DJANGO_DB_NAME}"]
      interval: 10s
      timeout: 5s
      retries: 5
    networks: [backend]

  django:                               # Gunicorn-served Django app
    build: ./django
    restart: unless-stopped
    command: gunicorn mysite.wsgi:application --bind 0.0.0.0:8000 --workers 3
    env_file: .env
    volumes:
      - static:/app/static
      - media:/app/media
    depends_on:
      db:
        condition: service_healthy
    networks: [backend, frontend]

  wp-db:                                # MariaDB for WordPress
    image: mariadb:11
    restart: unless-stopped
    environment:
      MARIADB_DATABASE: ${WP_DB_NAME}
      MARIADB_USER: ${WP_DB_USER}
      MARIADB_PASSWORD: ${WP_DB_PASSWORD}
      MARIADB_ROOT_PASSWORD: ${WP_DB_ROOT_PASSWORD}
    volumes:
      - wp_db:/var/lib/mysql
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      interval: 10s
      timeout: 5s
      retries: 5
    networks: [backend]

  wordpress:                            # official WordPress (Apache + PHP 8.3)
    image: wordpress:php8.3-apache
    restart: unless-stopped
    environment:
      WORDPRESS_DB_HOST: wp-db
      WORDPRESS_DB_NAME: ${WP_DB_NAME}
      WORDPRESS_DB_USER: ${WP_DB_USER}
      WORDPRESS_DB_PASSWORD: ${WP_DB_PASSWORD}
      WORDPRESS_CONFIG_EXTRA: |
        define('WP_HOME', 'https://example.com/blog');
        define('WP_SITEURL', 'https://example.com/blog');
    volumes:
      - wp_data:/var/www/html
    depends_on:
      wp-db:
        condition: service_healthy
    networks: [backend, frontend]

  nginx:                                # reverse proxy / router + TLS edge
    image: nginx:1.27
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
      - static:/var/www/django/static:ro
      - media:/var/www/django/media:ro
      - ./certbot/conf:/etc/letsencrypt:ro
      - ./certbot/www:/var/www/certbot:ro
    depends_on:
      django:
        condition: service_started
      wordpress:
        condition: service_started
    networks: [frontend]

volumes:
  django_db:
  wp_db:
  wp_data:
  static:
  media:

networks:
  frontend:
  backend:

What goes in the Django Dockerfile?

The django service builds from ./django/Dockerfile. Run collectstatic during the build so static files land in the image, then get copied into the shared static named volume that nginx serves.

# django/Dockerfile
FROM python:3.13-slim

ENV PYTHONUNBUFFERED=1 \
    PYTHONDONTWRITEBYTECODE=1

WORKDIR /app

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 install --no-cache-dir -r requirements.txt

COPY . .

# Collect static into /app/static -> shared with nginx via the named volume
RUN python manage.py collectstatic --noinput

EXPOSE 8000

# compose.yaml overrides this, but it's a sane default
CMD ["gunicorn", "mysite.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "3"]

How does nginx route to each app?

nginx is the single front door. Path-based routing keeps both apps on one domain: / goes to Gunicorn, /blog/ goes to WordPress, and Django’s /static/ and /media/ are served straight from the shared volume (never proxy static files through Django). Because both apps run on the same Compose network, proxy_pass targets them by service name.

For a deeper dive on the URL-rewrite edge cases of putting WordPress under /blog, see configuring a WordPress blog as a sub-directory alongside Django in nginx.

# nginx/default.conf
upstream django_app {
    server django:8000;
}

server {
    listen 80;
    server_name example.com www.example.com;

    client_max_body_size 64m;

    # Let's Encrypt HTTP-01 challenge (see TLS section)
    location /.well-known/acme-challenge/ {
        root /var/www/certbot;
    }

    # Django static & media straight from the shared volume
    location /static/ {
        alias /var/www/django/static/;
        access_log off;
        expires 30d;
    }
    location /media/ {
        alias /var/www/django/media/;
    }

    # WordPress blog -> Apache container
    location /blog/ {
        proxy_pass http://wordpress:80;
        proxy_set_header Host              $host;
        proxy_set_header X-Real-IP         $remote_addr;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    # Everything else -> Django (Gunicorn)
    location / {
        proxy_pass http://django_app;
        proxy_set_header Host              $host;
        proxy_set_header X-Real-IP         $remote_addr;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

Apache image vs PHP-FPM image — which WordPress?

The example uses wordpress:php8.3-apache so nginx can treat WordPress like any other HTTP backend and just proxy_pass to it — symmetrical with how it proxies Gunicorn, and the least fiddly choice. If you prefer the leaner wordpress:php8.3-fpm image, nginx must instead share WordPress’s files on a volume and fastcgi_pass to the FPM socket, because FPM speaks FastCGI, not HTTP:

# Only if you swap the wordpress service to the php8.3-fpm image.
# nginx and the wp container must share the same /var/www/html volume.
location /blog/ {
    root /var/www/html;
    index index.php;
    try_files $uri $uri/ /blog/index.php?$args;

    location ~ \.php$ {
        fastcgi_pass   wordpress:9000;     # FPM listens on 9000
        fastcgi_index  index.php;
        include        fastcgi_params;
        fastcgi_param  SCRIPT_FILENAME $document_root$fastcgi_script_name;
    }
}

How do you add HTTPS?

Terminate TLS at the edge. Two solid 2026 paths:

  • nginx + Let’s Encrypt (certbot). Add a companion certbot service that writes challenge files into the ./certbot/www volume nginx already serves under /.well-known/acme-challenge/, issue the cert once, then renew on a schedule. You keep full control of the nginx config.
  • Caddy or Traefik instead of nginx. Both provision and renew Let’s Encrypt certificates automatically — no manual certbot dance. They’re the lowest-friction option if you don’t need bespoke nginx tuning.
Reverse proxy Auto-TLS Config style Best when
nginx + certbot Manual (certbot) Imperative .conf You want maximum control / existing nginx skills
Caddy Automatic Tiny Caddyfile Simplest HTTPS with near-zero config
Traefik Automatic Labels / dynamic Many services, container labels, dashboards

Issue a certificate with the certbot companion:

# One-off certificate issue (HTTP-01 via the shared webroot)
docker compose run --rm --entrypoint "\
  certbot certonly --webroot -w /var/www/certbot \
  -d example.com -d www.example.com \
  --email you@example.com --agree-tos --no-eff-email" certbot

# ...or skip certbot entirely with Caddy as the router (auto-TLS):
# Caddyfile
# example.com {
#     handle_path /blog/* { reverse_proxy wordpress:80 }
#     handle           { reverse_proxy django:8000 }
# }

How do you launch and operate the stack?

One command builds images, creates the network and volumes, and starts everything in dependency order:

# Build and start the whole stack in the background
docker compose up -d --build

# Run Django migrations once the db is healthy
docker compose run --rm django python manage.py migrate

# Tail logs / list services / stop
docker compose logs -f
docker compose ps
docker compose down            # add -v to also remove named volumes

When should you graduate to Swarm or Kubernetes?

Docker Compose is built for a single host. That is exactly right for one Django app plus a WordPress blog, and it will comfortably serve real traffic on a well-sized VM. You only need an orchestrator when you outgrow one machine — for zero-downtime rolling deploys across nodes, autoscaling, or self-healing across a cluster.

The gentle next step is Docker Swarm, which reuses Compose-style files; see clustering Docker containers with Docker Swarm. For larger, multi-team platforms, Kubernetes is the industry standard — but it is a big jump in operational complexity, so adopt it only when the scaling pain is real.

Frequently Asked Questions

Should I use docker-compose or docker compose in 2026?

Use docker compose (with a space) — the Compose v2 plugin shipped with Docker Engine. The legacy docker-compose v1 binary (hyphenated, written in Python) is no longer maintained and is missing newer Compose Specification features. Every command in this guide assumes the v2 plugin.

Do I still need a version: key in the Compose file?

No. The top-level version: "3" / version: "3.8" line is obsolete under the current Compose Specification — it is ignored and only produces noise. Start your file directly with services:. Older tutorials that open with a version key are out of date.

Can Django and WordPress share one database?

It is strongly discouraged. Django targets PostgreSQL and WordPress targets MySQL/MariaDB, with completely different schemas and migration tooling. Running db (Postgres) and wp-db (MariaDB) as two separate containers keeps each app on its native engine and lets you back up, upgrade, or move them independently.

How do I serve WordPress under /blog instead of a subdomain?

Route the /blog/ location in nginx to the wordpress container and tell WordPress its address with WP_HOME and WP_SITEURL set to https://example.com/blog (via WORDPRESS_CONFIG_EXTRA). Prefer a subdomain like blog.example.com? Use a second nginx server block with that server_name proxying to wordpress:80 instead — both patterns work on the same stack.

How do healthchecks and depends_on prevent startup race conditions?

depends_on on its own only waits for a container to be created, not for the app inside it to be ready. Adding a healthcheck (for example pg_isready on Postgres) plus depends_on: condition: service_healthy makes Django wait until Postgres actually accepts connections — eliminating the classic "database is starting up" crash loop on first boot.

When should I move from Docker Compose to Kubernetes?

Stay on Compose while everything fits comfortably on one host — that covers most Django-plus-blog deployments. Move to Docker Swarm when you need basic multi-node scaling with familiar Compose files, and to Kubernetes only when you genuinely need cluster-wide autoscaling, self-healing, and zero-downtime rolling deploys across many machines and teams.

Get help running it in production

A two-container split is straightforward to stand up and easy to get subtly wrong in production — TLS renewal, static/media handling, healthcheck tuning, backups, and zero-downtime deploys all add up. MicroPyramid has shipped and maintained containerized Django and WordPress stacks for over 12 years across 50+ projects. If you’d like an expert to set up, harden, or take over the day-to-day, explore our server maintenance services or Django development services and tell us about your stack.

Share this article