Skip to content

Deploying a Django Site on a Subdomain

Reference deployment: sentio.franzvoid.is (built 2026-07-30). Django runs under gunicorn on a local port; Apache reverse-proxies to it and serves static files directly.

Substitute your own subdomain for sentio.franzvoid.is and pick an unused port (check with ss -ltnp; 3000 is taken by the Next.js site, 8000 by sentio).

1. Prerequisites

  • DNS A record for the subdomain pointing at the server (46.22.104.244).
  • Apache modules proxy, proxy_http, headers, rewrite enabled.
sudo a2enmod proxy proxy_http headers rewrite

2. Create the Project Directory

/var/www is owned by root, so create the directory with sudo and hand it to your user:

sudo mkdir -p /var/www/sentio.franzvoid.is
sudo chown franz:www-data /var/www/sentio.franzvoid.is

3. Virtualenv and Django

cd /var/www/sentio.franzvoid.is
python3 -m venv venv
./venv/bin/pip install --upgrade pip
./venv/bin/pip install django gunicorn
./venv/bin/django-admin startproject sentio .
./venv/bin/python manage.py startapp main

4. Secret Key

Never leave the generated SECRET_KEY in settings.py. Put it in a .env file that systemd loads. Single-quote the value — Django's key charset contains $, (, & and friends:

KEY=$(./venv/bin/python -c "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())")
printf "DJANGO_SECRET_KEY='%s'\n" "$KEY" > .env
chmod 600 .env

5. Production Settings

In sentio/settings.py:

import os

SECRET_KEY = os.environ.get('DJANGO_SECRET_KEY', 'django-insecure-dev-only-key')
DEBUG = os.environ.get('DJANGO_DEBUG', '') == '1'

ALLOWED_HOSTS = ['sentio.franzvoid.is', 'localhost', '127.0.0.1']
CSRF_TRUSTED_ORIGINS = ['https://sentio.franzvoid.is']

# Apache terminates TLS and proxies over plain HTTP on 127.0.0.1.
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
SESSION_COOKIE_SECURE = not DEBUG
CSRF_COOKIE_SECURE = not DEBUG

TIME_ZONE = 'Atlantic/Reykjavik'

STATIC_URL = '/static/'
STATIC_ROOT = BASE_DIR / 'staticfiles'

Then build the database and static files:

set -a && . ./.env && set +a
./venv/bin/python manage.py migrate
./venv/bin/python manage.py collectstatic --noinput

6. systemd Service

/etc/systemd/system/sentio.franzvoid.is.service:

[Unit]
Description=sentio.franzvoid.is Django app (gunicorn)
After=network.target

[Service]
Type=notify
User=franz
Group=www-data
WorkingDirectory=/var/www/sentio.franzvoid.is
EnvironmentFile=/var/www/sentio.franzvoid.is/.env
ExecStart=/var/www/sentio.franzvoid.is/venv/bin/gunicorn \
    --workers 3 \
    --bind 127.0.0.1:8000 \
    --access-logfile - \
    --error-logfile - \
    sentio.wsgi:application
ExecReload=/bin/kill -s HUP $MAINPID
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target

Type=notify works because gunicorn 20+ speaks the sd_notify protocol, so systemd knows when the app is genuinely ready rather than just forked.

sudo systemctl daemon-reload
sudo systemctl enable --now sentio.franzvoid.is.service
curl -I http://127.0.0.1:8000/

7. Apache Virtual Host

/etc/apache2/sites-available/sentio.franzvoid.is.conf:

<VirtualHost *:80>
    ServerName sentio.franzvoid.is

    Alias /static/ /var/www/sentio.franzvoid.is/staticfiles/
    <Directory /var/www/sentio.franzvoid.is/staticfiles>
        Require all granted
    </Directory>

    ProxyPreserveHost On
    ProxyPass /static/ !
    ProxyPass / http://127.0.0.1:8000/
    ProxyPassReverse / http://127.0.0.1:8000/

    RequestHeader set X-Forwarded-Proto "http"
    RequestHeader set X-Real-IP "%{REMOTE_ADDR}s"

    ErrorLog ${APACHE_LOG_DIR}/sentio.franzvoid.is_error.log
    CustomLog ${APACHE_LOG_DIR}/sentio.franzvoid.is_access.log combined
</VirtualHost>

ProxyPass /static/ ! must come before the catch-all ProxyPass /, otherwise the static alias is never reached and gunicorn is asked to serve CSS.

sudo a2ensite sentio.franzvoid.is.conf
sudo apache2ctl configtest
sudo systemctl reload apache2

8. HTTPS

sudo certbot --apache -d sentio.franzvoid.is --redirect

Fix the forwarded protocol header

Certbot copies the :80 vhost verbatim into -le-ssl.conf, which means the SSL vhost still advertises X-Forwarded-Proto "http". Django then thinks every request is insecure and CSRF checks on POST forms fail. Edit the generated file:

sudo sed -i 's|X-Forwarded-Proto "http"|X-Forwarded-Proto "https"|' \
    /etc/apache2/sites-available/sentio.franzvoid.is-le-ssl.conf
sudo systemctl reload apache2

9. Verify

curl -s -o /dev/null -w "%{http_code}\n" https://sentio.franzvoid.is/
curl -s -o /dev/null -w "%{http_code}\n" https://sentio.franzvoid.is/static/main/style.css
curl -s -o /dev/null -w "%{http_code}\n" https://sentio.franzvoid.is/admin/login/

A production 404 page (plain "Not Found") rather than Django's yellow debug page confirms DEBUG is off.

Day-to-Day

Task Command
Deploy code changes sudo systemctl restart sentio.franzvoid.is
Reload workers only sudo systemctl reload sentio.franzvoid.is
App logs sudo journalctl -u sentio.franzvoid.is -f
Apache logs sudo tail -f /var/log/apache2/sentio.franzvoid.is_error.log
After CSS/JS edits ./venv/bin/python manage.py collectstatic --noinput
After model changes manage.py makemigrations && manage.py migrate
Create admin user set -a && . ./.env && set +a && ./venv/bin/python manage.py createsuperuser

Troubleshooting

  • 502 Bad Gateway — gunicorn is down. systemctl status sentio.franzvoid.is.
  • DisallowedHost error — add the hostname to ALLOWED_HOSTS.
  • CSRF verification failed — check CSRF_TRUSTED_ORIGINS and the X-Forwarded-Proto header on the SSL vhost (step 8).
  • Unstyled pagescollectstatic not run, or staticfiles/ unreadable by www-data.