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
.gitignoreis 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
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
And on Render, set the Start Command to:
cd mysite && gunicorn mysite.wsgi:application
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
What each one does:
-
gunicorn — production WSGI server (replaces
runserver). - psycopg2-binary — PostgreSQL driver for Django.
-
dj-database-url — turns Render's
DATABASE_URLstring into Django'sDATABASESdict. - 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
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())"
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
# ...
]
Important:
cloudinary_storagemust come afterdjango.contrib.staticfiles. Otherwise it hijackscollectstaticwith 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",
# ...
]
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. -
STORAGEShas two independent keys:"default"handles media uploads (Cloudinary);"staticfiles"handles CSS/JS (WhiteNoise). - The
STATICFILES_STORAGE = ...line is a workaround fordjango-cloudinary-storage0.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
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
Make it executable:
chmod +x build.sh
On Windows, chmod won't work — use Git instead:
git add build.sh
git update-index --chmod=+x build.sh
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
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:
-
SECRET_KEY— The key you generated in step 3 -
DATABASE_URL— Internal Database URL from step 8 -
DJANGO_SETTINGS_MODULE—mysite.settings.production -
ALLOWED_HOSTS—your-name.onrender.com -
DJANGO_CSRF_TRUSTED_ORIGINS—https://your-name.onrender.com(must include scheme) -
PYTHON_VERSION— Match your local version (e.g.3.12.3) -
CLOUDINARY_CLOUD_NAME— From Cloudinary dashboard -
CLOUDINARY_API_KEY— From Cloudinary dashboard -
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 copied—collectstaticworked -
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_USERNAMEDJANGO_SUPERUSER_EMAILDJANGO_SUPERUSER_PASSWORD
Then append this line to build.sh (after migrate):
python manage.py createsuperuser --no-input || true
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)