feat: add deployment host and domain configuration guide

This commit is contained in:
Md Bayazid Bostame
2026-03-29 00:15:19 +01:00
parent d3c9281346
commit 0736bebd63
10 changed files with 151 additions and 6 deletions

View File

@@ -1,13 +1,32 @@
import os
import sys
from pathlib import Path
from urllib.parse import urlsplit
BASE_DIR = Path(__file__).resolve().parent.parent
def _split_csv_env(name: str, default: str = ''):
return [item.strip() for item in os.getenv(name, default).split(',') if item.strip()]
def _append_unique(items, value):
if value and value not in items:
items.append(value)
def _hostname_from_url(url: str) -> str:
try:
return (urlsplit(url).hostname or '').strip()
except ValueError:
return ''
SECRET_KEY = os.getenv('DJANGO_SECRET_KEY', 'unsafe-dev-key')
DEBUG = os.getenv('DJANGO_DEBUG', '0') == '1'
ALLOWED_HOSTS = [h.strip() for h in os.getenv('DJANGO_ALLOWED_HOSTS', 'localhost,127.0.0.1').split(',') if h.strip()]
CSRF_TRUSTED_ORIGINS = [o.strip() for o in os.getenv('DJANGO_CSRF_TRUSTED_ORIGINS', '').split(',') if o.strip()]
APP_DOMAIN = os.getenv('APP_DOMAIN', '').strip()
APP_BASE_URL = os.getenv('APP_BASE_URL', '').strip().rstrip('/')
ALLOWED_HOSTS = _split_csv_env('DJANGO_ALLOWED_HOSTS', 'localhost,127.0.0.1')
CSRF_TRUSTED_ORIGINS = _split_csv_env('DJANGO_CSRF_TRUSTED_ORIGINS', '')
_append_unique(ALLOWED_HOSTS, APP_DOMAIN)
_append_unique(ALLOWED_HOSTS, _hostname_from_url(APP_BASE_URL))
_append_unique(CSRF_TRUSTED_ORIGINS, APP_BASE_URL)
# Security hardening
SESSION_COOKIE_HTTPONLY = True

View File

@@ -406,5 +406,8 @@ def project_wiki_page_impl(request):
def developer_handbook_page_impl(request):
return render(request, 'workflows/developer_handbook.html')
def deployment_hosts_page_impl(request):
return render(request, 'workflows/deployment_hosts.html')
def release_checklist_page_impl(request):
return render(request, 'workflows/release_checklist.html')

View File

@@ -0,0 +1,78 @@
{% extends 'workflows/base_shell.html' %}
{% load static i18n %}
{% block title %}{% trans "Host & Domain Setup" %}{% endblock %}
{% block extra_css %}
<link rel="stylesheet" href="{% static 'workflows/css/docs_pages.css' %}" />
{% endblock %}
{% block shell_body %}
{% include 'workflows/includes/app_header.html' with header_show_home=1 header_inside_shell=1 %}
<div class="page-stack">
<div class="top">
<h1>{% trans "Host & Domain Setup" %}</h1>
<div class="actions">
<a class="btn btn-secondary" href="/admin-tools/handbook/">{% trans "Back to Handbook" %}</a>
</div>
</div>
<p class="sub">{% trans "Reference for configuring hostnames and origins correctly in Django deployments, including how to fix Invalid HTTP_HOST errors." %}</p>
<div class="box">
<h2>{% trans "Why this error happens" %}</h2>
<p>{% trans "Django rejects requests whose Host header is not present in ALLOWED_HOSTS. This validation runs before normal page routing, so a broken hostname cannot show a friendly in-app error page on that same host." %}</p>
<p>{% trans "Use this guide from a working hostname or IP address to correct the environment configuration." %}</p>
</div>
<div class="box">
<h2>{% trans "Recommended environment variables" %}</h2>
<ul>
<li><code>APP_DOMAIN</code>: {% trans "canonical hostname without scheme, for example" %} <code>workdock.bostame.de</code></li>
<li><code>APP_BASE_URL</code>: {% trans "canonical external URL with scheme, for example" %} <code>https://workdock.bostame.de</code></li>
<li><code>DJANGO_ALLOWED_HOSTS</code>: {% trans "comma-separated hostnames and IPs allowed to reach the app" %}</li>
<li><code>DJANGO_CSRF_TRUSTED_ORIGINS</code>: {% trans "comma-separated origins with scheme for POST and CSRF-safe requests" %}</li>
</ul>
<p>{% trans "The application automatically adds APP_DOMAIN and the hostname from APP_BASE_URL to the effective host allow-list. APP_BASE_URL is also added to trusted CSRF origins." %}</p>
</div>
<div class="box">
<h2>{% trans "Current test deployment example" %}</h2>
<pre><code>APP_DOMAIN=workdock.bostame.de
APP_BASE_URL=https://workdock.bostame.de
DJANGO_ALLOWED_HOSTS=192.168.2.55,localhost,127.0.0.1
DJANGO_CSRF_TRUSTED_ORIGINS=http://192.168.2.55:8088,https://workdock.bostame.de</code></pre>
</div>
<div class="box">
<h2>{% trans "Production example" %}</h2>
<pre><code>APP_DOMAIN=workdock.example.com
APP_BASE_URL=https://workdock.example.com
DJANGO_ALLOWED_HOSTS=workdock.example.com
DJANGO_CSRF_TRUSTED_ORIGINS=https://workdock.example.com</code></pre>
<p>{% trans "Production should run with HTTPS, DEBUG disabled, secure cookies enabled, and SSL redirect enabled." %}</p>
</div>
<div class="box">
<h2>{% trans "How to fix a live server" %}</h2>
<ol>
<li>{% trans "Log into the server and edit the active env file, for example" %} <code>/opt/workdock/.env.test</code></li>
<li>{% trans "Set APP_DOMAIN and APP_BASE_URL to the real external hostname." %}</li>
<li>{% trans "Keep the local IP in DJANGO_ALLOWED_HOSTS if you still use direct IP access." %}</li>
<li>{% trans "Restart the stack or rerun the deployment script." %}</li>
</ol>
<pre><code>cd /opt/workdock
nano .env.test
RUN_DJANGO_CHECK=0 DEPLOY_HEALTH_URL="http://127.0.0.1:8088/healthz/" ./scripts/deploy_stack.sh .env.test docker-compose.prod.yml</code></pre>
</div>
<div class="box">
<h2>{% trans "Important rules" %}</h2>
<ul>
<li><code>DJANGO_ALLOWED_HOSTS</code>: {% trans "hostnames only, no scheme" %}</li>
<li><code>DJANGO_CSRF_TRUSTED_ORIGINS</code>: {% trans "must include scheme, for example" %} <code>https://workdock.bostame.de</code></li>
<li>{% trans "If you use both IP and domain access, keep both in the configuration." %}</li>
<li>{% trans "A broken hostname setup cannot self-heal via the same broken host. Use a working host or IP to access this guide." %}</li>
</ul>
</div>
</div>
{% endblock %}

View File

@@ -33,6 +33,7 @@
<a href="#builders">Builders</a>
<a href="#testing">Testing</a>
<a href="#backup">Backup</a>
<a href="#hosts">Hosts & Domains</a>
<a href="#cicd">CI/CD</a>
<a href="#deploy">Deployment</a>
<a href="#troubleshooting">Troubleshooting</a>
@@ -267,7 +268,25 @@ make backup-verify BACKUP_DIR=backups/backup_YYYYmmdd_HHMMSS</code></pre>
<li>The staff UI uses the shared action-progress overlay for backup creation and verification so long-running actions present one standard app behavior.</li>
</ul>
<h2 id="cicd">13) CI/CD</h2>
<h2 id="hosts">13) Host and Domain Configuration</h2>
<ul>
<li>Primary env variables:
<ul>
<li><code>APP_DOMAIN</code>: canonical hostname without scheme</li>
<li><code>APP_BASE_URL</code>: canonical external URL including scheme</li>
<li><code>DJANGO_ALLOWED_HOSTS</code>: explicit host/IP allow-list</li>
<li><code>DJANGO_CSRF_TRUSTED_ORIGINS</code>: explicit origin allow-list with scheme</li>
</ul>
</li>
<li>The settings layer now folds <code>APP_DOMAIN</code> and the hostname from <code>APP_BASE_URL</code> into the effective allowed-host configuration automatically.</li>
<li><code>APP_BASE_URL</code> is also appended to trusted CSRF origins automatically.</li>
<li>Use <code>APP_DOMAIN</code> and <code>APP_BASE_URL</code> as the primary deployment-facing values instead of repeatedly editing long host/origin strings.</li>
<li>If you also reach the app by IP address, keep the IP in <code>DJANGO_ALLOWED_HOSTS</code> and, if needed, in <code>DJANGO_CSRF_TRUSTED_ORIGINS</code>.</li>
<li>The dedicated runbook page is available at <code>/admin-tools/deployment-hosts/</code>.</li>
<li>An <code>Invalid HTTP_HOST header</code> failure happens before normal page routing, so a broken hostname cannot render a custom error page on that same broken host. Use a working host or IP to access the runbook and fix the env file.</li>
</ul>
<h2 id="cicd">14) CI/CD</h2>
<ul>
<li>Repository model: one private GitHub repository, not separate dev/prod repositories.</li>
<li>Branch model: <code>develop</code> for the test deployment, <code>main</code> reserved for production.</li>
@@ -336,7 +355,7 @@ make backup-verify BACKUP_DIR=backups/backup_YYYYmmdd_HHMMSS</code></pre>
The current LAN test deployment intentionally uses <code>DJANGO_DEBUG=1</code> in <code>.env.test</code> because the security checks correctly reject insecure cookie settings when <code>DEBUG=0</code> and the deployment is still plain HTTP. This is acceptable for the internal test box only. Production must run with HTTPS and <code>DEBUG=0</code>.
</div>
<h2 id="deploy">14) Deployment</h2>
<h2 id="deploy">15) Deployment</h2>
<h3>Test server stack</h3>
<ul>
<li>Stack file: <code>docker-compose.prod.yml</code></li>
@@ -395,7 +414,7 @@ lxc.mount.entry: /dev/null sys/module/apparmor/parameters/enabled none bind 0 0<
<li>Take a snapshot commit before major next-phase work</li>
</ol>
<h2 id="troubleshooting">15) Troubleshooting</h2>
<h2 id="troubleshooting">16) Troubleshooting</h2>
<ul>
<li><strong>Page looks stale:</strong> restart <code>web</code> and hard-refresh browser</li>
<li><strong>Second request hangs:</strong> inspect web logs and verify health endpoint</li>
@@ -406,7 +425,7 @@ lxc.mount.entry: /dev/null sys/module/apparmor/parameters/enabled none bind 0 0<
<li><strong>Requests dependency warning appears:</strong> verify <code>chardet==5.2.0</code> is installed in the rebuilt image and restart <code>web</code>/<code>worker</code></li>
</ul>
<h2 id="security">16) Security and Maintenance Notes</h2>
<h2 id="security">17) Security and Maintenance Notes</h2>
<ul>
<li>Containers run as non-root <code>app</code> user.</li>
<li>Keep secrets in <code>.env</code>, not in tracked files.</li>

View File

@@ -48,6 +48,21 @@
</div>
</section>
<section class="card">
<div class="eyebrow">{% trans "Deployment" %}</div>
<h2>{% trans "Host & Domain Setup" %}</h2>
<p>{% trans "Runbook for ALLOWED_HOSTS, CSRF trusted origins, canonical domain variables, and how to resolve Invalid HTTP_HOST errors safely." %}</p>
<ul>
<li>{% trans "APP_DOMAIN and APP_BASE_URL" %}</li>
<li>{% trans "ALLOWED_HOSTS and CSRF origin rules" %}</li>
<li>{% trans "local test versus production examples" %}</li>
<li>{% trans "error recovery steps for wrong hostname setup" %}</li>
</ul>
<div class="actions">
<a class="btn btn-secondary" href="/admin-tools/deployment-hosts/">{% trans "Open Host Setup Guide" %}</a>
</div>
</section>
<section class="card">
<div class="eyebrow">{% trans "Release" %}</div>
<h2>{% trans "Release Checklist" %}</h2>

View File

@@ -49,6 +49,7 @@ urlpatterns = [
path('admin-tools/users/<int:user_id>/delete/', views.delete_user_from_admin, name='delete_user_from_admin'),
path('admin-tools/wiki/', views.project_wiki_page, name='project_wiki_page'),
path('admin-tools/developer-handbook/', views.developer_handbook_page, name='developer_handbook_page'),
path('admin-tools/deployment-hosts/', views.deployment_hosts_page, name='deployment_hosts_page'),
path('admin-tools/release-checklist/', views.release_checklist_page, name='release_checklist_page'),
path('admin-tools/audit-log/', views.audit_log_page, name='audit_log_page'),
path('admin-tools/backups/', views.backup_recovery_page, name='backup_recovery_page'),

View File

@@ -333,6 +333,10 @@ def project_wiki_page(request):
def developer_handbook_page(request):
return admin_config_views.developer_handbook_page_impl(request)
@_require_capability('view_docs')
def deployment_hosts_page(request):
return admin_config_views.deployment_hosts_page_impl(request)
@_require_capability('view_docs')
def release_checklist_page(request):