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.

Wagtail in productionEditorsadmin, previewWagtail pagesSites, routingImage renditionsgenerated on demandMedia storagedisk or S3Wagtail builds page URLs from its Sites records, not from ALLOWED_HOSTS or the request alone.
  1. Deploy the Django layer as usual (Gunicorn, Nginx or a platform, PostgreSQL, collectstatic).
  2. Set WAGTAILADMIN_BASE_URL to the public admin URL and WAGTAIL_SITE_NAME.
  3. Fix the default Site record: hostname example.com, port 443, root page, default site ticked.
  4. Put media (original images, renditions, documents) on persistent storage or S3.
  5. Run publish_scheduled every few minutes with a systemd timer or cron.
  6. Rebuild the search index after deploys that change indexed fields (update_index).
  7. 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
the Site record builds every absolute URLLeft at the default✕hostname localhost, port 80✕preview and email links break✕sitemap lists http://localhostSet for production✓www.example.com, port 443✓is_default_site on one record✓WAGTAILADMIN_BASE_URL matchesPort 443 makes Wagtail generate https:// linksdjango-deployment.com
Update it with a data migration or management command so every environment agrees.

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.

three kinds of media, one bucketCreatedIf lostOriginal imageson upload✕gone: back them upRenditionson first request per size!broken links until clearedDocumentson upload✕gone: back them upRenditions can be regenerated; originals and documents cannotdjango-deployment.com
Object storage keeps all three safe across deploys and shared between servers.

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-only when 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
scheduled pages need a timerEditor sets go-live09:00 tomorrowPage waitsstatus: scheduledTimer firesevery few minutespublish_scheduledpublishes due pagesCache purgedif a frontend cache is setWithout the timer, scheduled pages simply never go livedjango-deployment.com
Run the timer on one server only, even when the site runs on several.
# /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.

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

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.

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.

Keep going