How to Self-Host Wagtail CMS with Docker Compose

What Is Wagtail?

Wagtail is a CMS built on Django, Python’s most popular web framework. Unlike traditional CMS platforms like WordPress, Wagtail gives developers a flexible page model system — you define exactly what fields each page type has, then editors get a clean, modern admin interface to manage content. It powers sites from NASA to Google to the NHS. Wagtail is not a pre-packaged Docker image you pull and run — it’s a framework you build a project with, then containerize. This guide walks through both steps.

Prerequisites

  • A Linux server (Ubuntu 22.04+ recommended)
  • Docker and Docker Compose installed (guide)
  • Python 3.12+ installed locally (for project scaffolding)
  • 1 GB of free RAM (minimum)
  • 3 GB of free disk space
  • A domain name (optional, for remote access)

Step 1: Scaffold the Wagtail Project

On your local machine (or on the server), create a new Wagtail project:

pip install wagtail
wagtail start mysite
cd mysite

This generates a Django project with Wagtail pre-configured:

mysite/
├── mysite/
│   ├── settings/
│   │   ├── base.py
│   │   ├── dev.py
│   │   └── production.py
│   ├── urls.py
│   └── wsgi.py
├── home/              # Default home page app
├── search/            # Search app
├── manage.py
├── Dockerfile         # Production Docker config
└── requirements.txt

Step 2: Configure for Production

Edit mysite/settings/production.py to use environment variables:

from .base import *
import os

DEBUG = False
SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]
ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "*").split(",")

# Database — PostgreSQL via DATABASE_URL
import dj_database_url
DATABASES = {
    "default": dj_database_url.config(
        default="postgres://wagtail:wagtail@db:5432/wagtail"
    )
}

# Redis cache
CACHES = {
    "default": {
        "BACKEND": "django.core.cache.backends.redis.RedisCache",
        "LOCATION": os.environ.get("REDIS_URL", "redis://redis:6379"),
    }
}

# Static and media files
STATIC_ROOT = os.path.join(BASE_DIR, "staticfiles")
MEDIA_ROOT = os.path.join(BASE_DIR, "media")

Add production dependencies to requirements.txt:

# Add these lines
dj-database-url>=2.3.0
psycopg[binary]>=3.2.0
gunicorn>=23.0.0
whitenoise>=6.8.0

Step 3: Docker Compose Configuration

The scaffolded project includes a Dockerfile. If it doesn’t, create one:

FROM python:3.12-slim-bookworm AS builder

RUN apt-get update && apt-get install -y --no-install-recommends \
    build-essential libpq-dev && \
    rm -rf /var/lib/apt/lists/*

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

FROM python:3.12-slim-bookworm

RUN apt-get update && apt-get install -y --no-install-recommends \
    libpq5 libjpeg62-turbo libwebp7 && \
    rm -rf /var/lib/apt/lists/*

RUN useradd -m wagtail
WORKDIR /app
COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
COPY --from=builder /usr/local/bin/gunicorn /usr/local/bin/gunicorn
COPY . .

ENV DJANGO_SETTINGS_MODULE=mysite.settings.production
RUN python manage.py collectstatic --noinput

USER wagtail
EXPOSE 8000
CMD ["gunicorn", "mysite.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "3"]

Create a docker-compose.yml file:

services:
  app:
    build: .
    container_name: wagtail
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_started
    environment:
      DJANGO_SETTINGS_MODULE: mysite.settings.production
      DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY}
      DATABASE_URL: postgres://wagtail:${DB_PASSWORD}@db:5432/wagtail
      REDIS_URL: redis://redis:6379
      ALLOWED_HOSTS: ${ALLOWED_HOSTS:-*}
    ports:
      - "8000:8000"
    volumes:
      - wagtail_media:/app/media
    restart: unless-stopped
    networks:
      - wagtail-net

  db:
    image: postgres:16-alpine
    container_name: wagtail-db
    environment:
      POSTGRES_DB: wagtail
      POSTGRES_USER: wagtail
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    volumes:
      - wagtail_db:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U wagtail"]
      interval: 10s
      timeout: 5s
      retries: 5
    restart: unless-stopped
    networks:
      - wagtail-net

  redis:
    image: redis:7-alpine
    container_name: wagtail-redis
    restart: unless-stopped
    networks:
      - wagtail-net

volumes:
  wagtail_db:
  wagtail_media:

networks:
  wagtail-net:
    driver: bridge

Create a .env file:

# Django secret key — generate with: python3 -c "import secrets; print(secrets.token_urlsafe(50))"
DJANGO_SECRET_KEY=change-me-to-a-long-random-string

# Database password — CHANGE THIS
DB_PASSWORD=change-me-strong-password

# Comma-separated allowed hostnames
ALLOWED_HOSTS=cms.example.com,localhost

Step 4: Deploy

Build and start the stack:

docker compose up -d --build

Run database migrations:

docker exec -it wagtail python manage.py migrate

Create the admin superuser:

docker exec -it wagtail python manage.py createsuperuser

Access the admin interface at http://your-server-ip:8000/admin/.

Configuration

Wagtail vs WordPress vs Ghost

FeatureWagtailWordPressGhost
LanguagePython (Django)PHPNode.js
Content modelCustom page typesPosts + pagesPosts + pages
Admin UIModern, streamlinedPlugin-heavyMinimal, focused
CustomizationCode-first (Python)Plugin-firstTheme/API
APIREST + GraphQL readyREST (plugin for GraphQL)Content API
Docker imageBuild your ownPre-builtPre-built
Learning curveHigh (Django knowledge)LowLow
Best forCustom content structuresBlogs, general websitesNewsletter + blog

Key Wagtail Settings

SettingDescriptionDefault
WAGTAIL_SITE_NAMESite name shown in adminProject name
WAGTAILADMIN_BASE_URLPublic URL for admin notificationsNone
WAGTAILSEARCH_BACKENDSSearch engine config (database or Elasticsearch)Database
WAGTAILIMAGES_MAX_UPLOAD_SIZEMax image upload size10 MB
WAGTAIL_ENABLE_UPDATE_CHECKCheck for Wagtail updatesTrue

Add these to base.py in your settings directory.

Adding Elasticsearch (Optional)

For production sites with lots of content, replace the default database search:

services:
  elasticsearch:
    image: docker.elastic.co/elasticsearch/elasticsearch:8.17.0
    container_name: wagtail-search
    environment:
      - discovery.type=single-node
      - xpack.security.enabled=false
      - "ES_JAVA_OPTS=-Xms256m -Xmx256m"
    volumes:
      - es_data:/usr/share/elasticsearch/data
    restart: unless-stopped
    networks:
      - wagtail-net

Update settings:

WAGTAILSEARCH_BACKENDS = {
    "default": {
        "BACKEND": "wagtail.search.backends.elasticsearch8",
        "URLS": ["http://elasticsearch:9200"],
        "INDEX": "wagtail",
    }
}

Reverse Proxy

Place Wagtail behind a reverse proxy for SSL. With Nginx Proxy Manager:

  • Scheme: http
  • Forward Hostname: wagtail or your server IP
  • Forward Port: 8000
  • Enable SSL and force HTTPS

Set ALLOWED_HOSTS to include your public domain and WAGTAILADMIN_BASE_URL to your HTTPS URL. See our Reverse Proxy Setup guide.

Backup

Back up the PostgreSQL database and media files:

# Database
docker exec wagtail-db pg_dump -U wagtail wagtail > wagtail_backup_$(date +%Y%m%d).sql

# Media uploads
docker run --rm -v wagtail_media:/data -v $(pwd):/backup alpine \
  tar czf /backup/wagtail_media_$(date +%Y%m%d).tar.gz -C /data .

Include both wagtail_db and wagtail_media volumes in your backup strategy.

Troubleshooting

Static Files Not Loading (404 Errors)

Symptom: Admin interface loads with broken CSS/JS. Fix: Run collectstatic inside the container:

docker exec -it wagtail python manage.py collectstatic --noinput

Ensure whitenoise is installed and configured in MIDDLEWARE (it should be in the scaffolded template).

Database Migration Errors

Symptom: Container crashes with “relation does not exist” errors. Fix: Run migrations manually:

docker exec -it wagtail python manage.py migrate

If the database was newly created, this populates all required tables.

Image Upload Fails

Symptom: Uploading images in the admin returns a 500 error. Fix: Check that the wagtail_media volume is mounted and the wagtail user has write permissions. Verify libjpeg62-turbo and libwebp7 are installed in the Docker image.

Permission Denied on Media Directory

Symptom: File permission errors when uploading content. Fix: Ensure the media directory is owned by the wagtail user:

docker exec -it wagtail chown -R wagtail:wagtail /app/media

Resource Requirements

  • RAM: ~200 MB idle (Gunicorn + 3 workers), ~500 MB under load
  • CPU: Low-moderate (Django ORM handles most processing)
  • Disk: ~500 MB for application, plus media storage and database

Verdict

Wagtail is the best choice if you’re a Python developer who wants full control over your content model. No other CMS gives you the same flexibility to define custom page types with structured fields. The trade-off is real: you need Django experience, there’s no pre-built Docker image, and the initial setup is more involved than docker compose up. If you want a CMS you can deploy in 5 minutes, use WordPress or Ghost. If you want a headless CMS for API-driven sites, Directus or Strapi are easier to containerize. Wagtail wins when your content structure is complex enough that WordPress’s post/page model feels limiting.

Comments