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,rewriteenabled.
2. Create the Project Directory¶
/var/www is owned by root, so create the directory with sudo and hand it to your user:
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.
8. HTTPS¶
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:
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_ORIGINSand theX-Forwarded-Protoheader on the SSL vhost (step 8). - Unstyled pages —
collectstaticnot run, orstaticfiles/unreadable bywww-data.