Deploy to a VPS

Buy a domain, provision an Ubuntu VPS, and serve Westloop with Nginx, Gunicorn, PostgreSQL, and Let’s Encrypt.

Goal for this part

Put Westloop on the public internet: a domain you own, an Ubuntu VPS you control, PostgreSQL on that box, Gunicorn under systemd, Nginx as the reverse proxy, and Let’s Encrypt HTTPS. This is the production path for a first social app — not a platform that hides the Linux underneath.

What you buy, and why these vendors

PieceVendorWhy
DomainCloudflare RegistrarAt-cost pricing, DNS in the same UI you will edit. Namecheap is the fine alternative.
DNSCloudflareProxy can sit in front later; for this lesson use DNS-only (grey cloud) so Certbot talks to your VPS directly.
VPSHetzner Cloud CX22 (2 vCPU / 4 GB / Ubuntu 24.04)Enough RAM for Gunicorn + Postgres + Nginx. Contabo VPS S or DigitalOcean Droplet 4 GB work the same. Avoid 1 GB boxes — Postgres will swap.
EmailPostmarkTransactional mail that actually arrives. Mailgun is the common alternative. Do not send reset mail from a random Gmail SMTP on a new VPS; it will land in spam.

Create an A record for westloop.example (and optional www) pointing at the VPS IPv4. Wait until dig +short westloop.example returns that IP before you run Certbot.

Harden the box

SSH in as root once, create a sudo user, disable password SSH, and enable the firewall. These commands are the baseline, not optional flavor.

adduser deploy
usermod -aG sudo deploy
rsync --archive --chown=deploy:deploy ~/.ssh /home/deploy/
ufw allow OpenSSH
ufw allow http
ufw allow https
ufw --force enable

Then log in as deploy and install the runtime:

sudo apt update
sudo apt install -y python3-venv python3-pip postgresql nginx git \
  certbot python3-certbot-nginx libpq-dev build-essential

PostgreSQL on the VPS

Create a role and database that the app user can reach on localhost only. Do not expose port 5432 to the internet.

sudo -u postgres psql <<'SQL'
CREATE USER westloop WITH PASSWORD 'choose-a-long-secret';
CREATE DATABASE westloop OWNER westloop;
GRANT ALL PRIVILEGES ON DATABASE westloop TO westloop;
SQL

App checkout and environment

git clone https://github.com/michaeldunga1/fcc-django-social.git /home/deploy/westloop
cd /home/deploy/westloop/12-Deploy-VPS
python3 -m venv /home/deploy/westloop/.venv
source /home/deploy/westloop/.venv/bin/activate
pip install -r ../requirements.txt gunicorn

Write secrets in /etc/westloop.env (mode 640, owner root:deploy). Never commit this file.

DJANGO_SETTINGS_MODULE=westloop.settings
SECRET_KEY=generate-with-python-secrets
DEBUG=0
ALLOWED_HOSTS=westloop.example,www.westloop.example
DATABASE_URL=postgres://westloop:choose-a-long-secret@127.0.0.1:5432/westloop
POSTMARK_USER=your-server-token
POSTMARK_TOKEN=your-server-token
DEFAULT_FROM_EMAIL=Westloop <hello@westloop.example>
set -a
source /etc/westloop.env
set +a
python manage.py migrate
python manage.py collectstatic --noinput
python manage.py createsuperuser

Gunicorn + systemd

The development server is single-threaded and is not for the public internet. Gunicorn speaks WSGI. systemd restarts it if it dies.

# /etc/systemd/system/westloop.service
[Unit]
Description=Westloop
After=network.target postgresql.service

[Service]
User=deploy
Group=deploy
WorkingDirectory=/home/deploy/westloop/12-Deploy-VPS
EnvironmentFile=/etc/westloop.env
ExecStart=/home/deploy/westloop/.venv/bin/gunicorn \
  --bind 127.0.0.1:8000 --workers 3 westloop.wsgi:application
Restart=on-failure

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now westloop
curl -sS http://127.0.0.1:8000/health

You should see ok from the health route. If Gunicorn fails, journalctl -u westloop -e is the first place to look — usually SECRET_KEY, DATABASE_URL, or the working directory is wrong.

Nginx, static files, and media

Nginx terminates HTTP, later TLS, and serves /static/ and /media/ directly so Gunicorn never touches CSS or avatars.

# /etc/nginx/sites-available/westloop
server {
    listen 80;
    server_name westloop.example www.westloop.example;
    client_max_body_size 4M;

    location /static/ {
        alias /home/deploy/westloop/12-Deploy-VPS/staticfiles/;
    }
    location /media/ {
        alias /home/deploy/westloop/12-Deploy-VPS/media/;
    }
    location / {
        proxy_pass http://127.0.0.1:8000;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}
sudo ln -sf /etc/nginx/sites-available/westloop /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx

HTTPS with Certbot

Once DNS points at the box:

sudo certbot --nginx -d westloop.example -d www.westloop.example \
  --agree-tos --redirect -m you@westloop.example

Set SECURE_SSL_REDIRECT, SESSION_COOKIE_SECURE, and CSRF_COOKIE_SECURE to true when DEBUG is false. Trust X-Forwarded-Proto so Django knows the request was HTTPS:

SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True

Ship checklist

  • https://westloop.example/health returns ok
  • Register a throwaway account, upload an avatar, compose a post
  • Complete a password reset using a real inbox (Postmark)
  • DEBUG is 0 and the secret is not the teaching default
  • Postgres is not listening on 0.0.0.0

Later upgrades: object storage for media if you add a second VPS, Redis if you add Channels for live notices, and a managed Postgres if you outgrow one disk. None of that is required to launch Westloop.

Common mistakes

DisallowedHost means ALLOWED_HOSTS missed the domain. CSS 404s mean collectstatic was skipped or the Nginx alias path is wrong. Certbot fails when DNS still points at the old host or Cloudflare is orange-clouded in front of HTTP-01. If sessions drop after systemctl restart westloop, SECRET_KEY is being generated at import time instead of read from the env file.

Try this

From your laptop, curl -I https://westloop.example and confirm a 200 and a strict-transport-security header after Certbot. Then restart Gunicorn and refresh a signed-in tab — you should still be Ada, because the session cookie is signed with a stable key.