DEV Community

Cover image for Deploying a Wagtail site on Render. A practical guide
Giovanni D'Amico
Giovanni D'Amico

Posted on

Deploying a Wagtail site on Render. A practical guide

Why I wrote this

I recently finished the Wagtail tutorial and wanted to push my site to production on Render. The official docs get you most of the way, but a handful of small issues cost me more time than I liked. This post is the guide I wish I'd had: a complete, working path from a fresh tutorial project to a live site, with Cloudinary handling media uploads.

I covered:

  • A Wagtail site running on Render with Gunicorn
  • A PostgreSQL database
  • Static files served by WhiteNoise
  • Cloudinary for images hosting

Who is this for

If you’ve just finished (or are partway through) the Wagtail tutorial and you’re ready to get your site live, this guide is for you. It’s written for early‑stage developers who want a practical, end‑to‑end deployment path using Render and Cloudinary, not an advanced deep dive.

We’ll cover the basic but essential pieces that tend to trip people up (production settings, environment variables, static files, media storage, database setup, and common errors), hoping this can help you in the process and eventual debugging.

Why Cloudinary: Render's filesystem is ephemeral

Render's web services wipe the disk on every deploy or restart. Any images you upload through Wagtail's admin would vanish the next time the app rebuilds. Cloudinary fixes this by storing media files in the cloud and serving them via CDN.

You don't change any models, templates, or blocks. ForeignKey('wagtailimages.Image', ...), ImageBlock(), FieldPanel('image'), and {% image %} all stay exactly the same. Cloudinary plugs in at the storage layer.

Starting point

This guide assumes:

  • You've finished the Wagtail tutorial (or have a Wagtail site to deploy)
  • Your .gitignore is set up
  • You haven't touched deployment yet
  • I won't cover the database data transfer. I simply recreated the pages after deployment.

A note on the project structure

The issue I had and what you should try to avoid

This was my file structure ready for deployment, and my Git (and GitHub) repo ROOT was not the same as manage.py

mysite/
├── .venv/
├── mysite/
│   ├── base/ blog/ home/ portfolio/ search/
│   ├── media/
│   ├── mysite/
│   │   ├── settings/
│   │   │   ├── base.py ← shared settings
│   │   │   ├── dev.py ← local dev
│   │   │   └── production.py ← we'll rewrite this
│   │   ├── static/ templates/
│   │   ├── urls.py wsgi.py
│   ├── db.sqlite3 manage.py requirements.txt
Enter fullscreen mode Exit fullscreen mode

If your Git repo root is NOT the same folder as manage.py:

This happens when you run git init in a parent folder (e.g. personal/) while your Wagtail project lives in a subfolder (e.g. personal/mysite/). You can check by running git rev-parse --show-toplevel.

If it doesn't point to the folder containing manage.py, you have this issue.

Option 1 (cleanest — no code changes needed): On Render, when creating your Web Service, set the Root Directory field to mysite (or whatever your subfolder is called). This tells Render to run all commands from that directory instead of the repo root. Your build.sh and start command stay exactly as written — no cd needed.

Option 2 (if you can't set Root Directory): Add cd mysite to the top of build.sh:

#!/usr/bin/env bash
set -o errexit
cd mysite
pip install -r requirements.txt
python manage.py collectstatic --no-input --clear
python manage.py migrate
Enter fullscreen mode Exit fullscreen mode

And on Render, set the Start Command to:

cd mysite && gunicorn mysite.wsgi:application
Enter fullscreen mode Exit fullscreen mode

To avoid this in future projects, always run wagtail start mysite . (note the . at the end) so project files are created in the current directory, and then run git init in that same directory — so the repo root and manage.py are in the same place.


Phase A — Prepare your project locally

1. Install production dependencies

Activate your virtual environment, then:

pip install gunicorn psycopg2-binary dj-database-url whitenoise django-cloudinary-storage cloudinary
Enter fullscreen mode Exit fullscreen mode

What each one does:

  • gunicorn — production WSGI server (replaces runserver).
  • psycopg2-binary — PostgreSQL driver for Django.
  • dj-database-url — turns Render's DATABASE_URL string into Django's DATABASES dict.
  • whitenoise — serves static files (CSS/JS) in production.
  • django-cloudinary-storage — redirects file uploads to Cloudinary.
  • cloudinary — the underlying SDK.

Lock them in:

pip freeze > requirements.txt
Enter fullscreen mode Exit fullscreen mode

2. Create a Cloudinary account

Sign up at http://cloudinary.com/ (free tier: 25 GB storage, 25 GB bandwidth/month — plenty for a portfolio). From the dashboard, grab:

  • Cloud Name
  • API Key
  • API Secret

Keep them safe, they'll become environment variables on Render. Never commit them.

3. Generate a production SECRET_KEY

Your dev.py has a hardcoded key that's fine for local work. Production needs its own:

python -c "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())"
Enter fullscreen mode Exit fullscreen mode

Copy the output somewhere safe. Don't put it in any file in the repo.

4. Update base.py — apps and middleware

Add Cloudinary to INSTALLED_APPS. Order matters:

INSTALLED_APPS = [
    # ... your apps ...
    "django.contrib.staticfiles",
    "cloudinary_storage",          # AFTER staticfiles
    "cloudinary",                  # AFTER cloudinary_storage
    "django.contrib.postgres",     # required for Wagtail search on PostgreSQL
    # ...
]
Enter fullscreen mode Exit fullscreen mode

Important: cloudinary_storage must come after django.contrib.staticfiles. Otherwise it hijacks collectstatic with its own version and silently collects zero files.

Then add WhiteNoise middleware — directly after SecurityMiddleware:

MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "whitenoise.middleware.WhiteNoiseMiddleware",  # ← here
    "django.contrib.sessions.middleware.SessionMiddleware",
    # ...
]
Enter fullscreen mode Exit fullscreen mode

WhiteNoise does nothing when DEBUG=True, so this is safe for local dev too.

5. Configure production.py

This is what Render is going to use during deployment. Some key aspects about how it is set up:

  • os.environ["SECRET_KEY"] (not .get) means the app crashes immediately if you forget to set it — you'll know at once.
  • STORAGES has two independent keys: "default" handles media uploads (Cloudinary); "staticfiles" handles CSS/JS (WhiteNoise).
  • The STATICFILES_STORAGE = ... line is a workaround for django-cloudinary-storage 0.3.0, which still references a setting Django 6 removed.
from .base import *

import os
import dj_database_url

# ===== SECURITY =====
SECRET_KEY = os.environ["SECRET_KEY"]
DEBUG = False
ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "").split(",")
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SECURE_SSL_REDIRECT = True
CSRF_TRUSTED_ORIGINS = os.environ.get("DJANGO_CSRF_TRUSTED_ORIGINS", "").split(",")

# ===== DATABASE =====
DATABASES = {
    "default": dj_database_url.config(conn_max_age=600)
}

# ===== MEDIA (Cloudinary) & STATIC (WhiteNoise) =====
CLOUDINARY_STORAGE = {
    "CLOUD_NAME": os.environ["CLOUDINARY_CLOUD_NAME"],
    "API_KEY": os.environ["CLOUDINARY_API_KEY"],
    "API_SECRET": os.environ["CLOUDINARY_API_SECRET"],
}

STORAGES = {
    "default": {
        "BACKEND": "cloudinary_storage.storage.MediaCloudinaryStorage",
    },
    "staticfiles": {
        "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
    },
}

# Shim for django-cloudinary-storage 0.3.0 on Django 6+
STATICFILES_STORAGE = STORAGES["staticfiles"]["BACKEND"]

# ===== WAGTAIL =====
WAGTAIL_REDIRECTS_FILE_STORAGE = "cache"

# ===== LOCAL OVERRIDES =====
try:
    from .local import *
except ImportError:
    pass
Enter fullscreen mode Exit fullscreen mode

6. Add a build.sh

Render needs to know what to do when it builds your app. Create a new file called build.sh in your project root (same level as manage.py):

#!/usr/bin/env bash
set -o errexit

pip install -r requirements.txt
python manage.py collectstatic --no-input --clear
python manage.py migrate
Enter fullscreen mode Exit fullscreen mode

Make it executable:

chmod +x build.sh
Enter fullscreen mode Exit fullscreen mode

On Windows, chmod won't work — use Git instead:

git add build.sh
git update-index --chmod=+x build.sh
Enter fullscreen mode Exit fullscreen mode

7. Check, commit and push

Sanity-check: .venv/, db.sqlite3, __pycache__/, and media/ should NOT be in the commit. requirements.txt, build.sh, and the updated settings files SHOULD be.

Then commit and push.


Phase B — Set up Render

8. Create a PostgreSQL database

Render dashboard → New → PostgreSQL. Pick a region close to you, leave the defaults, choose the Free tier.

Once it's created, copy the Internal Database URL from the Connections section. Save it for later.

Free databases expire after 30 days. Upgrade to Basic ($6/mo) if you want it to be persistent.

9. Create a Web Service

Wagtail's starter template ships a Dockerfile and .dockerignore. Remove them first or Render will default to Docker and hide the Build/Start command fields:

git rm mysite/Dockerfile mysite/.dockerignore
Enter fullscreen mode Exit fullscreen mode

Dashboard → New → Web Service → connect your GitHub repo. Then:

  • Runtime: Python 3
  • Build Command: ./build.sh
  • Start Command: gunicorn mysite.wsgi:application
  • Instance type: Free (spins down after 15 min) or Starter ($7/mo, always on)

10. Environment variables

On Render, find the Environment variables settings and set all of these:

  1. SECRET_KEY — The key you generated in step 3
  2. DATABASE_URL — Internal Database URL from step 8
  3. DJANGO_SETTINGS_MODULEmysite.settings.production
  4. ALLOWED_HOSTSyour-name.onrender.com
  5. DJANGO_CSRF_TRUSTED_ORIGINShttps://your-name.onrender.com (must include scheme)
  6. PYTHON_VERSION — Match your local version (e.g. 3.12.3)
  7. CLOUDINARY_CLOUD_NAME — From Cloudinary dashboard
  8. CLOUDINARY_API_KEY — From Cloudinary dashboard
  9. CLOUDINARY_API_SECRET — From Cloudinary dashboard

11. Deploy

Click Create Web Service (or Manual Deploy → Deploy latest commit). Watch the logs for:

  • Installing requirements — pip ran
  • static files copiedcollectstatic worked
  • Running migrations — PostgreSQL tables created
  • Listening on 0.0.0.0:... — Gunicorn is up

12. Create a superuser

The Shell tab is not available on the Free plan. Use the environment variable method below instead.

If you’re on a paid plan, you can use the Shell tab and run python manage.py createsuperuser directly.

Otherwise, on a free plan, use the env-var approach:

Add three variables on Render:

  • DJANGO_SUPERUSER_USERNAME
  • DJANGO_SUPERUSER_EMAIL
  • DJANGO_SUPERUSER_PASSWORD

Then append this line to build.sh (after migrate):

python manage.py createsuperuser --no-input || true
Enter fullscreen mode Exit fullscreen mode

Commit, push, redeploy. Log in at /admin/. Afterwards, remove the line and the three env vars. Just take note of them somewhere safe. No, not on the back of your latest gas bill.

13. Add content

Your local SQLite data isn't transferred. Just re-create your pages in the Wagtail admin. Images now go straight to Cloudinary.

In this guide, I'm not covering data transferring, but you can look into it if you have lots of content. Images will still need to be reuploaded.


Phase C — Verify

  • Homepage loads at https://your-name.onrender.com/
  • CSS and styling look right (→ WhiteNoise is working)
  • /admin/ login works
  • Upload a test image → it shows on the frontend
  • The image appears in your Cloudinary Media Library

Debugging and Issues I hit (and fixes)

A log of everything that went wrong and how I solved it. If you're debugging, Ctrl+F here first.

1 - AttributeError: 'Settings' object has no attribute 'STATICFILES_STORAGE'

django-cloudinary-storage 0.3.0 still references a setting Django 6 removed.

Fix: add STATICFILES_STORAGE = STORAGES["staticfiles"]["BACKEND"] after the STORAGES block.

2 - SystemCheckError: 'django.contrib.postgres' must be in INSTALLED_APPS

Wagtail search uses SearchVectorField, which needs this app when PostgreSQL is the backend.

Fix: add "django.contrib.postgres" to INSTALLED_APPS in base.py.

3 - NodeNotFoundError during migrate

If you mix Wagtail versions (e.g. generate migrations on 7.3rc1, then downgrade to 7.2), migration parents break.

Fix: stick to one version across dev and production.

4 - Bad Request (400) on the live site

ALLOWED_HOSTS doesn't match the actual domain.

Fix: set it to exactly your-name.onrender.com, no scheme, no trailing spaces.

5 - CSRF_TRUSTED_ORIGINS build error

Env var missing → .split(",") returns [""] → Django rejects it.

Fix: set DJANGO_CSRF_TRUSTED_ORIGINS to https://your-name.onrender.com (with the scheme).

6 - 0 static files copied

cloudinary_storage was listed before django.contrib.staticfiles, so its custom collectstatic silently did nothing.

Fix: reorder — cloudinary_storage comes after staticfiles.

7 - Render defaulting to Docker

It auto-detected the Wagtail starter's Dockerfile.

Fix: git rm mysite/Dockerfile mysite/.dockerignore.

8 - build.sh can't find manage.py

Git repo root was one level above the Wagtail project.

Fix: set Render's Root Directory, or add cd mysite to the build and start commands.


Wrapping up and personal considerations

A quick note before the victory lap: this is the procedure that worked for me. It's not necessarily the foolproof, one-size-fits-all path. I got here by researching, experimenting, and leaning on AI to reason through my specific scenario (a fresh tutorial project, Django 6, Wagtail 7, Render + Cloudinary). Your stack versions or project layout might nudge you off this exact route, and that's fine.

That said, if this guide worked for you, you should now have:

  • A Wagtail site live on Render
  • Persistent PostgreSQL
  • Media uploads going to Cloudinary.
  • Static files properly served in production

Where I went from here

Once everything was working end-to-end, I made a few follow-up moves to turn this from a free experiment into something I actually want to keep online:

  • Upgraded the Render web service to a paid plan so it stops spinning down after 15 minutes of inactivity, no more cold-start delay when someone visits, maybe overkill for a portfolio, but I can always downgrade.
  • Bought a custom domain through Cloudflare and pointed it at the Render service, which also gave me their DNS and SSL setup for free.
  • Moving the PostgreSQL database to a paid Render plan before the 30-day free expiry, so I don't lose the database (and have to redo migrations and content) just as things settle in.

None of these is required to get the site live. The free tier is actually enough to prove the whole pipeline works, but they're the natural next steps once you want the site to stick around.

If this helped, or if you hit a fresh issue I haven't covered, I'd love to hear about it. Mostly, I hope this proves useful to someone approaching the same case as mine: a first Wagtail project, freshly out of the tutorial, trying to get it deployed on Render. Happy deploying.

Top comments (0)