How to deploy & self-host Wagtail on DigitalOcean

How to deploy & self-host Wagtail on DigitalOcean

We may earn an affiliate commission through purchases made from our guides and tutorials.

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 use django-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 allow www-data to read the socket; ensure the service is running.
  • Static files missing: re-run collectstatic; confirm Nginx alias paths match STATIC_ROOT/MEDIA_ROOT.
  • Security nits: run python manage.py check --deploy until it’s quiet. Don’t ignore warnings about ALLOWED_HOSTS or SECURE_* 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.

Was this helpful?

Thanks for your feedback!
Alex is the resident editor and oversees all of the guides published. His past work and experience include Colorlib, Stack Diary, Hostvix, and working with a number of editorial publications. He has been wrangling code and publishing his findings about it since the early 2000s.

Leave a comment

Your email address will not be published. Required fields are marked *