Domain-Driven Multi-Host Subdomain Routing in a Modular Django Application
How app.djangoplay.org and issues.djangoplay.org share one Django deployment and one login — while the apex domain, the docs site, and even the auth hostname never reach that deployment at all.
CB### 1. Five Subdomains, Two That Reach Django
If you look at everything under djangoplay.org, you'll find five hostnames in active use: the apex domain itself, docs., app., issues., and auth.. From a browser, they all look like the same site. Underneath, only two of them — app. and issues. — actually reach the Django application. Here's how the other three opt out:
- The apex domain and
docs.are static builds served entirely by Cloudflare Pages. Requests to either never touch the VM that Django runs on — there isn't even an Nginx server block listening for them. auth.djangoplay.orgisn't a real address at all. It's the issuer name AuthX (DjangoPlay's identity service) puts inside every JWT it signs. Nothing resolves that hostname in DNS — it exists purely so a token can say "issued by djangoplay's auth", the way a passport lists an issuing country without that country being a physical stamp booth.
That distinction — which hostnames exist versus which hostnames are Django's problem — is easy to blur, and blurring it is exactly how a codebase ends up carrying routing logic for something that moved away months ago and nobody remembered to delete.
2. Routing by Hostname with django-hosts
For the two hostnames that do reach Django, django-hosts decides which URL configuration handles the request, based on the incoming Host header — before Django's own URL resolver even starts:
# paystream/hosts.py
from django_hosts import host, patterns
host_patterns = patterns(
"",
host(r"issues", "paystream.urlconf.subdomains.issues", name="issues"),
host(r"app", "paystream.urlconf.default", name="default"),
)A request to issues.djangoplay.org matches r"issues" and goes to a small, focused urlconf that only knows about the issue tracker and one shared support endpoint. A request to app.djangoplay.org matches r"app" and gets the full application. DEFAULT_HOST still points at that same "default" entry as a fallback, so an unrecognized Host header doesn't just fall through to nowhere.
3. Deleted, Not Stubbed: What Happens to a Subdomain That Leaves Django
This pattern used to have a third line, for docs. When docs.djangoplay.org moved to its current static build, that line kept pointing at a urlconf module that no longer existed in the project. In practice that meant: if a request with a docs.* Host header ever did land on Django — a local dev misconfiguration, say — it wouldn't 404 cleanly. It would 500 with a ModuleNotFoundError, because the pattern still promised a destination that wasn't there anymore.
The fix wasn't to add back a placeholder module just to stop the crash. It was to delete the pattern outright, and leave a one-line comment explaining why it's gone rather than a mystery. That's a small decision, but it's the right instinct: when the infrastructure moves, the code should say so — not quietly keep pointing at where things used to live.
4. Middleware Order Isn't Optional
django-hosts has to resolve the Host header before anything else touches routing, and clean up after everything else has finished. That gives its two middleware classes fixed positions — one first, one last — with everything else sandwiched between:
MIDDLEWARE = [
"django_hosts.middleware.HostsRequestMiddleware",
"core.middleware.request_id.RequestIDMiddleware",
"core.middleware.client_ip.ClientIPMiddleware",
# ... security, sessions, auth, everything else ...
"core.middleware.api_request_logging.APIRequestLoggingMiddleware",
"django_hosts.middleware.HostsResponseMiddleware",
]Get this ordering wrong and host resolution either happens too late to affect routing, or the response middleware runs before something that depends on it — a bug that only shows up on one specific subdomain, which makes it miserable to chase down later.
5. One Login Shared Between the Two Django Subdomains
Since app. and issues. are the same Django process split by hostname, a person shouldn't have to log in twice just because they crossed from one to the other. That means the session and CSRF cookies need to be valid across both — not scoped to whichever one happened to set them.
The base setting is deliberately left unset, and each environment fills it in with a leading dot so the cookie is valid for the whole domain and anything under it:
# paystream/settings/prod.py
SESSION_COOKIE_DOMAIN = f".{SITE_HOST}" # ".djangoplay.org"
CSRF_COOKIE_DOMAIN = f".{SITE_HOST}"
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = TrueSITE_HOST here is always the bare apex — never app.djangoplay.org — precisely so that leading dot covers both Django subdomains at once. The cookie's reach happens to cover the static apex and docs pages too, but that's incidental; those pages don't run any code that reads a session, so it never comes into play there. ALLOWED_HOSTS follows the same shape, with an explicit .djangoplay.org wildcard so a new subdomain doesn't silently get a 400 the moment DNS starts pointing it at the same box.
6. Reusing a View Across Hosts Without Duplicating It
The app and issues urlconfs aren't sealed off from each other — they can share code freely. The support form is a good example: it's mounted on the main app as part of the full helpdesk app, and it's also mounted as a single narrow path on issues., pointing at the exact same view function:
# default host
path("helpdesk/", include(("helpdesk.urls", "helpdesk"), namespace="helpdesk")),
s host
path("helpdesk/support/", support_view, name="issues_helpdesk_support"),No second implementation to keep in sync — just the same function mounted wherever it makes sense for that audience. That's the real payoff of keeping this as one Django application instead of splitting into separate services: isolated URL surfaces, without losing the ability to just reuse a function.
7. A Third List That Has Nothing to Do With Routing
Here's a distinction worth being precise about, because it trips people up: the subdomains list in DjangoPlay's own config isn't the same thing as the django-hosts patterns above. It's a separate list — app, issues, and docs — used only to build full URLs (for things like a footer link to the docs site), not to route anything:
def get_all_subdomains() -> dict[str, str]:
# app -> https://app.djangoplay.org
# issues -> https://issues.djangoplay.org
# docs -> https://docs.djangoplay.org (still built, never routed)
...docs stays in that list even though it has no django-hosts pattern anymore, because DjangoPlay still needs to generate a correct link to the docs site — it just never needs to handle a request that arrives at it. Two lists, two jobs: one says "here's a URL you can print somewhere," the other says "here's a request I know how to answer."
Takeaways
- A hostname existing and a hostname being your app's responsibility are two different facts. Keep routing code only for the one that's actually true.
- When infrastructure moves, delete the old code — don't stub it. A silent 500 is worse than a clean 404; a comment explaining a removal is worth more than a placeholder pretending nothing changed.
- Middleware order is part of your architecture, not an implementation detail. Host-resolution middleware has fixed positions for a reason — get it wrong and the bug only shows up on specific hostnames.
- Shared login needs a shared, deliberately-scoped cookie domain — not whatever Django happens to default to.
- A list of subdomains for building links isn't the same as a list of subdomains you route. Conflating the two is how you end up either routing something you shouldn't, or breaking a link to something you no longer serve.