Alright—let’s get your Wagtail site onto a solid, boringly-reliable stack on DigitalOcean. No gimmicks: Ubuntu LTS, PostgreSQL, Gunicorn, Nginx, systemd, and Let’s Encrypt. It’s the same recipe teams have been shipping for years because it keeps working.
Wagtail, in case you’re new to it, is a Python CMS built on Django. It’s the antithesis of heavy, monolithic systems—clean admin, sensible page models, and first-class image handling. You get Django’s maturity with a CMS that doesn’t fight you. Under the hood it’s still Django, so the deployment story is the Django playbook: run your app behind a WSGI server (Gunicorn), put Nginx in front, and mind your production settings.
What you’ll build & need
- A DigitalOcean account.
- A Wagtail project running on an Ubuntu LTS Droplet
- Gunicorn (systemd-managed) talking to your app via a Unix socket
- Nginx reverse proxy terminating TLS with a Let’s Encrypt certificate
- PostgreSQL (either on the Droplet or DigitalOcean Managed DB)
- Static/media files served correctly, secure Django settings, and a repeatable deployment routine
If you’ve deployed Django before, this will feel familiar. The only Wagtail-specific bits are its settings and collectstatic/media pattern, which we’ll cover.
Create a Droplet and log in
Pick Ubuntu 24.04 LTS (or the latest LTS). Choose a basic plan (1–2GB RAM is fine to start), add your SSH key, and create the Droplet. SSH in as root, then create a normal user.
ssh root@your_server_ip
adduser deploy
usermod -aG sudo deploy
rsync -av ~/.ssh /home/deploy/
chown -R deploy:deploy /home/deploy/.ssh
su - deploy
Keep the OS tidy:
sudo apt update && sudo apt upgrade -y
sudo apt install -y build-essential curl git ufw
Secure the box (UFW + basic hygiene):
sudo ufw allow OpenSSH
sudo ufw enable
sudo ufw status
We’ll let Nginx open 80/443 once installed. Nothing fancy—simple, predictable firewall rules.
Install system dependencies (Python, Postgres client, Nginx)
sudo apt install -y python3 python3-venv python3-pip python3-dev \
libpq-dev postgresql postgresql-contrib \
nginx
If you prefer DigitalOcean Managed Postgres (less ops for you), you can skip the local postgresql*
packages and use DO’s connection string instead. The Django+Gunicorn+Nginx pattern remains identical.
Create a project folder and Python venv
mkdir -p ~/sites/wagtailproj
cd ~/sites/wagtailproj
python3 -m venv .venv
source .venv/bin/activate
Install Wagtail (this pulls Django too):
pip install --upgrade pip wheel
pip install wagtail gunicorn psycopg2-binary
Start a new Wagtail project:
wagtail start mysite .
You now have a Django project with Wagtail installed and a manage.py
.
Create the database and user (PostgreSQL)
sudo -u postgres psql
Inside psql
:
CREATE DATABASE wagtail_db;
CREATE USER wagtail_user WITH PASSWORD 'replace-with-strong-password';
ALTER ROLE wagtail_user SET client_encoding TO 'utf8';
ALTER ROLE wagtail_user SET default_transaction_isolation TO 'read committed';
ALTER ROLE wagtail_user SET timezone TO 'UTC';
GRANT ALL PRIVILEGES ON DATABASE wagtail_db TO wagtail_user;
\q
Alternatively, if you use DigitalOcean Managed Postgres, note the provided host/port/SSL settings and use that DSN in Django. The application layer setup is the same.
Configure Django/Wagtail for production
Open mysite/settings/base.py
(Wagtail’s default layout) or mysite/settings.py
if you opted for a single settings file. Set ALLOWED_HOSTS
, switch to Postgres, wire static/media paths, and disable debug. A clean way is to load secrets from environment variables.
Create .env
(we’ll load it in settings/base.py
):
cd ~/sites/wagtailproj
cat > .env << 'EOF'
DEBUG=False
SECRET_KEY=replace-with-50-characters-of-random
ALLOWED_HOSTS=yourdomain.com,www.yourdomain.com
DATABASE_URL=postgres://wagtail_user:replace-with-strong-password@127.0.0.1:5432/wagtail_db
CSRF_TRUSTED_ORIGINS=https://yourdomain.com,https://www.yourdomain.com
EOF
Install django-environ
to read the file:
pip install django-environ
In mysite/settings/base.py
, near the top:
import environ, os
env = environ.Env()
environ.Env.read_env(os.path.join(BASE_DIR, '..', '.env'))
DEBUG = env.bool("DEBUG", default=False)
SECRET_KEY = env("SECRET_KEY")
ALLOWED_HOSTS = env.list("ALLOWED_HOSTS", default=[])
CSRF_TRUSTED_ORIGINS = env.list("CSRF_TRUSTED_ORIGINS", default=[])
Database:
DATABASES = {
"default": env.db("DATABASE_URL")
}
Static & media:
STATIC_URL = "/static/"
STATIC_ROOT = os.path.join(BASE_DIR, "..", "static")
MEDIA_URL = "/media/"
MEDIA_ROOT = os.path.join(BASE_DIR, "..", "media")
Wagtail has several additional settings you can tune later (image quality, search backends, contrib settings). For core deployment, the usual Django production checklist still applies: secure secret key, HTTPS, allowed hosts, secure cookies, etc.
Initialise the project (migrate, superuser, collectstatic)
source .venv/bin/activate
python manage.py migrate
python manage.py createsuperuser
python manage.py collectstatic --noinput
If you want a smoke test with Django’s dev server (optional, only on localhost):
python manage.py runserver 127.0.0.1:8000
Don’t use the dev server in production; we’re moving to Gunicorn next.
Gunicorn: run WSGI under systemd
First, verify Gunicorn can import your project:
cd ~/sites/wagtailproj
source .venv/bin/activate
gunicorn --bind 127.0.0.1:8001 mysite.wsgi
Stop it (Ctrl-C) and create systemd units. We’ll use the socket/activation pattern so systemd brings Gunicorn up on demand.
Create /etc/systemd/system/gunicorn.socket
:
sudo tee /etc/systemd/system/gunicorn.socket > /dev/null << 'EOF'
[Unit]
Description=gunicorn socket
[Socket]
ListenStream=/run/gunicorn.sock
SocketUser=www-data
SocketGroup=www-data
SocketMode=0660
[Install]
WantedBy=sockets.target
EOF
Create /etc/systemd/system/gunicorn.service
:
sudo tee /etc/systemd/system/gunicorn.service > /dev/null << 'EOF'
[Unit]
Description=gunicorn daemon
Requires=gunicorn.socket
After=network.target
[Service]
User=deploy
Group=www-data
WorkingDirectory=/home/deploy/sites/wagtailproj
Environment="PATH=/home/deploy/sites/wagtailproj/.venv/bin"
ExecStart=/home/deploy/sites/wagtailproj/.venv/bin/gunicorn \
--workers 3 \
--timeout 60 \
--bind unix:/run/gunicorn.sock \
mysite.wsgi:application
Restart=on-failure
[Install]
WantedBy=multi-user.target
EOF
Enable and start:
sudo systemctl daemon-reload
sudo systemctl enable --now gunicorn.socket
sudo systemctl status gunicorn.socket
sudo systemctl start gunicorn.service
sudo systemctl status gunicorn.service
Gunicorn’s docs show exactly this socket/service approach; it plays nicely with Nginx via a Unix socket.
Nginx: reverse proxy and static/media
Create a server block:
sudo tee /etc/nginx/sites-available/wagtail.conf > /dev/null << 'EOF'
server {
listen 80;
server_name yourdomain.com www.yourdomain.com;
client_max_body_size 20M;
location /static/ {
alias /home/deploy/sites/wagtailproj/static/;
}
location /media/ {
alias /home/deploy/sites/wagtailproj/media/;
}
location / {
include proxy_params;
proxy_pass http://unix:/run/gunicorn.sock;
}
}
EOF
Enable it and test:
sudo ln -s /etc/nginx/sites-available/wagtail.conf /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl restart nginx
sudo ufw allow 'Nginx Full'
At this point, your site should respond on HTTP with static/media served directly by Nginx and app traffic forwarded to Gunicorn.
HTTPS with Let’s Encrypt (Certbot)
Install Certbot’s Nginx plugin and obtain a certificate:
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d yourdomain.com -d www.yourdomain.com
Follow the prompts and choose the redirect option. Certbot will install a systemd timer to auto-renew. You can dry-run renewal with:
sudo certbot renew --dry-run
This is the universal Let’s Encrypt flow; it hasn’t changed much over the years: install plugin, issue cert, auto-renew, and (optionally) enable HSTS once you’re confident HTTPS is universal.
Production checklist — quick hits
Set these in the Django settings module your production server loads (for example, settings/production.py
). Ensure DJANGO_SETTINGS_MODULE
points to that module before you deploy, then restart the app to apply changes.
First, run Django’s deployment checks in your activated virtual environment, and fix any findings before proceeding. This confirms that core security and configuration flags are effective.
source .venv/bin/activate
python manage.py check --deploy
Next, enable HTTPS-only behavior and secure cookies in the production settings. These flags enforce TLS, mark cookies as secure, and send an HSTS header so browsers prefer HTTPS on repeat visits.
# settings/production.py
SECURE_HSTS_SECONDS = 31536000
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = False # switch to True only after verifying every subdomain uses HTTPS
SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
Finally, verify that every route, hostname, and subdomain serves HTTPS without mixed content. After you confirm full coverage and stable redirects, set SECURE_HSTS_PRELOAD = True
and submit the domain for HSTS preload. Re-run python manage.py check --deploy
, confirm headers and cookies in responses, and proceed with release.
Wagtail admin, assets, and images
Log in to /admin/
with the superuser you created. Upload a few images and documents to confirm media is saving in MEDIA_ROOT
and serving over Nginx. Later, you can tune Wagtail’s image rendition quality and other settings (it’s all documented and stable).
Ongoing deployment (zero-panic routine)
When you push code updates:
cd ~/sites/wagtailproj
source .venv/bin/activate
git pull
pip install -r requirements.txt
python manage.py migrate
python manage.py collectstatic --noinput
sudo systemctl restart gunicorn
If you ever suspect Gunicorn is stale or wedged (happens), sudo systemctl restart gunicorn
is your reset button. The socket will re-handshake with Nginx.
Optional but sensible
- Redis cache for snappier admin and page fragments.
- Media storage on S3/Spaces if you expect large uploads (swap
MEDIA_*
to usedjango-storages
). - Separate database (DigitalOcean Managed PostgreSQL) for easy backups and updates. The app code doesn’t change—just the
DATABASE_URL
.
Troubleshooting cheats
- Gunicorn won’t start: check
sudo journalctl -u gunicorn -xe
and confirm the paths in your unit file (WorkingDirectory, PATH, socket location). Most failures are typos or wrong virtualenv paths. - Bad Gateway (502): Nginx can’t talk to Gunicorn. Make sure
/run/gunicorn.sock
exists and permissions allowwww-data
to read the socket; ensure the service is running. - Static files missing: re-run
collectstatic
; confirm Nginxalias
paths matchSTATIC_ROOT
/MEDIA_ROOT
. - Security nits: run
python manage.py check --deploy
until it’s quiet. Don’t ignore warnings aboutALLOWED_HOSTS
orSECURE_*
settings.
Why this stack works
Wagtail recommends WSGI (Gunicorn) because Wagtail itself doesn’t ship async views—it’s the straightforward, time-tested path. Nginx is fast at static/media and reverse proxying; Gunicorn keeps Django honest; systemd ensures everything comes up clean on reboot; Certbot handles TLS and renewals with no drama. That’s it. No container sprawl, no mystery. You can always add Docker later if you really need it, but this gets your site online today with the right defaults.