Deploy Wagtail CMS to Production
Deploy a Wagtail site to production: Sites and WAGTAILADMIN_BASE_URL, image renditions and media storage, scheduled publishing, search indexing, document serving, and caching.
Problem statement
Wagtail is a Django application, so the usual Django production steps apply: Gunicorn, a reverse proxy, PostgreSQL, static files, secrets, and HTTPS. But a Wagtail site has extra moving parts that a plain Django deployment guide misses, and they tend to fail quietly: page URLs pointing at localhost, preview and notification links with the wrong domain, image renditions that fill the disk or disappear on redeploy, scheduled pages that never publish, and a search index that is out of date.
This guide covers the Wagtail-specific production configuration on top of a standard Django stack.
- Deploy the Django layer as usual (Gunicorn, Nginx or a platform, PostgreSQL,
collectstatic). - Set
WAGTAILADMIN_BASE_URLto the public admin URL andWAGTAIL_SITE_NAME. - Fix the default Site record: hostname
example.com, port443, root page, default site ticked. - Put media (original images, renditions, documents) on persistent storage or S3.
- Run
publish_scheduledevery few minutes with a systemd timer or cron. - Rebuild the search index after deploys that change indexed fields (
update_index). - Configure document serving and, if you use a CDN, frontend cache invalidation.
Step-by-step solution
1. Core settings
# settings/production.py
WAGTAIL_SITE_NAME = "Example"
WAGTAILADMIN_BASE_URL = "https://example.com"
ALLOWED_HOSTS = ["example.com", "www.example.com"]
CSRF_TRUSTED_ORIGINS = ["https://example.com", "https://www.example.com"]
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
WAGTAILIMAGES_MAX_UPLOAD_SIZE = 15 * 1024 * 1024
WAGTAILIMAGES_MAX_IMAGE_PIXELS = 64_000_000
WAGTAILADMIN_BASE_URL is used in notification emails and places where Wagtail builds admin links outside a request. Without it, moderation emails contain wrong or missing links.
Match the upload limits to your proxy (client_max_body_size in Nginx) and to DATA_UPLOAD_MAX_MEMORY_SIZE so large images fail with a clear message rather than a proxy error.
2. Fix the Site record
Wagtail routes requests and builds page URLs from Settings → Sites in the admin (the wagtailcore_site table). A new project has a default site with hostname localhost and port 80. In production:
- Hostname:
example.com - Port:
443 - Site root page: your home page
- Is default site: ticked
With port 80 and HTTPS, page.full_url produces http://example.com/... or includes an explicit port, which breaks canonical tags, sitemaps, and preview links. For multi-site setups, add one Site per hostname.
You can set this in a data migration or management command so a fresh environment is correct without manual admin clicks:
from wagtail.models import Site
site = Site.objects.get(is_default_site=True)
site.hostname = "example.com"
site.port = 443
site.save()
3. Media, images, and renditions
Wagtail stores original images and documents in media storage, and generates resized renditions on demand the first time a template requests a size. Renditions are also files in media storage, plus rows in the database.
Consequences for production:
- Media must be persistent across deploys. On containers or PaaS, use object storage with django-storages:
STORAGES = {
"default": {
"BACKEND": "storages.backends.s3.S3Storage",
"OPTIONS": {"bucket_name": "example-media", "default_acl": "private", "querystring_auth": False},
},
"staticfiles": {"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage"},
}
If the bucket is served publicly through a CDN, set custom_domain so URLs point at it.
- Rendition generation uses Pillow (through Willow). Make sure the production image includes Pillow built with the formats you use (WebP, AVIF support depends on the Pillow build).
- The first request for a new rendition is slower because it resizes the image. With remote storage, it also downloads the original. Warm renditions for key pages after large imports if needed.
- If renditions are lost (for example, media on an ephemeral disk), Wagtail's database still thinks they exist and serves broken links. Clear stale rows with
python manage.py wagtail_update_image_renditions --purge-onlywhen available in your version, or delete the rendition rows and let them regenerate.
4. Scheduled publishing
Pages with a "go live" or "expiry" date are published by a management command, not by a background thread:
# /etc/systemd/system/wagtail-publish.service
[Unit]
Description=Publish scheduled Wagtail pages
[Service]
Type=oneshot
User=myapp
WorkingDirectory=/srv/myapp/current
EnvironmentFile=/etc/myapp.env
ExecStart=/srv/myapp/current/.venv/bin/python manage.py publish_scheduled
# /etc/systemd/system/wagtail-publish.timer
[Unit]
Description=Run Wagtail scheduled publishing every 5 minutes
[Timer]
OnCalendar=*:0/5
Persistent=true
[Install]
WantedBy=timers.target
sudo systemctl daemon-reload
sudo systemctl enable --now wagtail-publish.timer
systemctl list-timers | grep wagtail
Older Wagtail versions call the command publish_scheduled_pages. Run it on exactly one server when you have several.
5. Search
The default database search backend indexes content in PostgreSQL. After deploying changes to search_fields, or after bulk imports, rebuild:
python manage.py update_index
For large sites, Elasticsearch or OpenSearch backends scale better; their index must also be rebuilt after mapping changes.
6. Document serving
Documents are served through a Wagtail view by default so privacy settings can be enforced. With S3 storage, set:
WAGTAILDOCS_SERVE_METHOD = "redirect"
redirect sends users to the storage URL (signed if the bucket is private), keeping large downloads off Gunicorn. serve_view streams through Django, which works with private collections but costs worker time.
7. Caching and CDN
Page responses can be cached by a CDN or Nginx because most visitors are anonymous. When editors publish, the cache must be purged. Wagtail's wagtail.contrib.frontend_cache app supports Cloudflare, CloudFront, and HTTP purge backends:
INSTALLED_APPS += ["wagtail.contrib.frontend_cache"]
WAGTAILFRONTENDCACHE = {
"cloudflare": {
"BACKEND": "wagtail.contrib.frontend_cache.backends.CloudflareBackend",
"BEARER_TOKEN": os.environ["CLOUDFLARE_API_TOKEN"],
"ZONEID": os.environ["CLOUDFLARE_ZONE_ID"],
},
}
Never cache the admin, preview URLs, or responses for logged-in users.
Verification check:
- Page URLs in the sitemap and canonical tags start with
https://example.com/and contain no port. - Uploading an image and viewing a page that uses it creates a rendition in media storage.
- A page scheduled a few minutes ahead goes live on time.
- Admin notification emails link to the correct domain.
Explanation
Wagtail adds content-management state on top of Django: a page tree mapped to hostnames by Site records, generated media derivatives, time-based publishing, and a search index. Each of these has a production counterpart (correct Site rows, persistent media, a scheduler, and index maintenance) that a standard Django deployment does not set up by itself. Once they are in place, Wagtail behaves like any other well-deployed Django app.
Edge cases / notes
Multiple servers
All servers must share media storage and the database. Only one should run publish_scheduled. Renditions generated on one server are visible to all when storage is shared.
Upgrades
Wagtail releases include migrations and sometimes changes to management commands and settings. Read the upgrade considerations for each version and run migrations as part of the deploy.
When to automate this
Put the Site record setup in a data migration or a bootstrap command, the scheduled publishing timer in server provisioning, and update_index in the deploy script when search fields change. These are the Wagtail steps most often forgotten on a new environment.
Next steps
- Build the base stack with Deploy Django with Gunicorn and Nginx on Ubuntu.
- Move images and documents to object storage with How to Store Django Media Files on Amazon S3.
- Learn more about timers in Schedule Django Management Commands with Cron or systemd Timers.
- If images 404 after deploy, see Fix Django Media Files Not Serving (Uploads Broken in Production).
FAQ
Why do Wagtail page URLs show localhost or port 80?
The default Site record still has hostname localhost and port 80. Change it to your domain and port 443 in Settings → Sites.
Why don't scheduled Wagtail pages publish?
Scheduled publishing requires running publish_scheduled periodically. Add a systemd timer or cron job on one server.
Can Wagtail store images on S3?
Yes. Configure django-storages as the default storage. Originals and renditions are then stored in the bucket.
Do I need Elasticsearch for Wagtail search?
No. The database backend works well for many sites. Use Elasticsearch or OpenSearch when you need advanced relevance tuning or have a very large content set.