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
| Feature | Wagtail | WordPress | Ghost |
|---|---|---|---|
| Language | Python (Django) | PHP | Node.js |
| Content model | Custom page types | Posts + pages | Posts + pages |
| Admin UI | Modern, streamlined | Plugin-heavy | Minimal, focused |
| Customization | Code-first (Python) | Plugin-first | Theme/API |
| API | REST + GraphQL ready | REST (plugin for GraphQL) | Content API |
| Docker image | Build your own | Pre-built | Pre-built |
| Learning curve | High (Django knowledge) | Low | Low |
| Best for | Custom content structures | Blogs, general websites | Newsletter + blog |
Key Wagtail Settings
| Setting | Description | Default |
|---|---|---|
WAGTAIL_SITE_NAME | Site name shown in admin | Project name |
WAGTAILADMIN_BASE_URL | Public URL for admin notifications | None |
WAGTAILSEARCH_BACKENDS | Search engine config (database or Elasticsearch) | Database |
WAGTAILIMAGES_MAX_UPLOAD_SIZE | Max image upload size | 10 MB |
WAGTAIL_ENABLE_UPDATE_CHECK | Check for Wagtail updates | True |
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:
wagtailor 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.
Related
Get self-hosting tips in your inbox
Get the Docker Compose configs, hardware picks, and setup shortcuts we don't put in articles. Weekly. No spam.
Comments