Subdomain Routing
Learning Website with Django
Chapter 2 ยท Subdomain Routing
Chapter 1 gave the project a layout and a list of sites. This chapter makes the project answer as
those sites: a request for languages.osztromok.com must reach the languages pages, and a request
for systems.osztromok.com/hungary/ must not. The mechanism is small, a middleware and one URL
configuration per site, but it sits underneath every later chapter, so it is worth building carefully and
testing hard.
curl. The Apache and Gunicorn part could not be run on the
machine used to write it, and is marked as such.
How a Request Becomes a Site
Steps 4 to 6 are Django and are the subject of this chapter. Steps 2 and 3 are the last section.
The Site Middleware
A middleware is a small class that wraps every request. This one looks at the host name, decides the site, and attaches three things to the request:
request.get_host()does two jobs. It returns the host name, and it checks it againstALLOWED_HOSTS, so an unknown name is refused by Django with a 400 before any of your code runs.- Strip the port and lower-case the name before the lookup:
Languages.Localhost:8000andlanguages.localhostare the same site. - The lookup table comes from the site map.
HOST_TO_SITEis built by one function insites_config.py,host_map(env), so the settings and the middleware cannot disagree. - Order matters. The middleware must be listed before
CommonMiddleware, which readsrequest.urlconf(for example when it adds a missing slash).
One URL Configuration per Site
Django normally has one list of routes, named by ROOT_URLCONF. But if a middleware sets
request.urlconf, Django uses that module for the request instead. So each site can have its
own routes, built from the site map: the languages site has routes for france,
germany, hungary, japan and culture, and nothing else.
The result is that the routing itself enforces the site boundaries. There is no
if site == "languages" in any view: /linux/shell-and-scripting/vim/ on the languages
site matches no route, so it is a 404, and the same path on the systems site matches and is served. A test
checks every folder of every site against every other site (Exercise 1).
(?:/.*)?$, which means “the folder, then optionally a slash and more”. A
looser pattern that only matched the start of the path would let /hungary-extra/ through as if it
were the hungary folder. One test exists for exactly that case.
Two Kinds of “Unknown”
| Request | Result | Who answers |
|---|---|---|
A host name that is not in ALLOWED_HOSTS | 400 | Django, before the middleware |
| An allowed host with no site (should not happen) | 404, “Unknown site” | The middleware |
| A known site, a folder it does not have | 404 | That site's URL configuration |
| A known site, a page that does not exist | 404 | The view (Chapters 3 and 4) |
The root domain, osztromok.com and www, is not mapped yet: it will become the landing
page after the areas have moved, and until then the old site keeps answering for it. A request for it reaching
this project would get the 400.
One Process or One per Site
With LW_SITE unset, one process serves every site. With LW_SITE=languages, the process
narrows both ALLOWED_HOSTS and HOST_TO_SITE to that one site. Real requests to both,
from Exercise 2:
| Request | All-sites process | Pinned to languages |
|---|---|---|
languages.localhost, path / | 200 | 200 |
languages.localhost, path /hungary/x/ | 200 | 200 |
systems.localhost, path /hungary/x/ | 404 (no such route on that site) | 400 (host not allowed) |
Host evil.example.org | 400 | 400 |
Host 127.0.0.1:8765 (no proxy host name) | 400 | 400 |
Names on Your Own Machine
To try a named site locally you need the name to reach your machine. The obvious idea is
languages.localhost, and it cannot be relied on. On the Windows machine used to
write this course, Python could not resolve it, and on Ubuntu 24.04 under WSL,
getent hosts languages.localhost returned nothing, although localhost worked. Some
browsers resolve *.localhost themselves, but curl, scripts and other tools use the system
resolver. Whether it works on Debian depends on the name-service modules installed, so check on your own
development machine before relying on it.
| Option | How |
|---|---|
| 1. The hosts file (reliable) | Add one line, 127.0.0.1 languages.localhost systems.localhost ..., to /etc/hosts. A five-line script prints it from the site map. |
2. curl --resolve | curl --resolve languages.localhost:8000:127.0.0.1 http://languages.localhost:8000/ for a quick test with no file edited. |
3. curl -H "Host: ..." | curl -H "Host: languages.localhost" http://127.0.0.1:8000/ sends the name but connects by address. |
Behind Apache and Gunicorn
In production the request reaches Django through two other programs. Django cannot see the original request for itself, so three things must be passed on correctly:
| What Django needs to know | How the proxy tells it | Django setting |
|---|---|---|
| The host name the visitor used | Apache: ProxyPreserveHost On | None (the default: use the Host header) |
| Whether the visitor used HTTPS | Apache: RequestHeader set X-Forwarded-Proto "https" | SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") |
| Which headers to trust | Trust as few as possible | Leave USE_X_FORWARDED_HOST off |
- Without
ProxyPreserveHost OnDjango would see the address Apache used to reach it, not the visitor's host name. The real request would be refused with a 400, exactly like the “127.0.0.1” row above. - The HTTPS header is trusted only when you say so. A test shows Django ignores
X-Forwarded-Protoby default and believes it onceSECURE_PROXY_SSL_HEADERis set. That is also what stopsSECURE_SSL_REDIRECTfrom looping: Django finally sees that the request was HTTPS. USE_X_FORWARDED_HOSTis a trap. With it on, a visitor can pick the site by sending anX-Forwarded-Hostheader. A test shows a request addressed tolanguages.localhostbeing served as the systems site.
The Gunicorn service and the Apache virtual host are in the Exercise 3 solution. They were not run
here, because neither program is installed on the machine used to write the course. Test them on the server with
apache2ctl configtest and curl -I before you rely on them.
Hands-On Exercises
Add subdomain routing to the Chapter 1 project: a host_map() function, a site middleware (placed before CommonMiddleware), one URL configuration per site built from the site map, placeholder views, and tests that check each site on its own host, the port and case handling, unknown hosts, and every folder against every other site.
Start the development server twice (all sites, and pinned to one) and request both with curl using --resolve and a custom Host header. Record the status of each request, then write a script that prints the hosts-file line for your development machine.
Write tests for what Django does behind a proxy (the HTTPS header, the redirect loop, the forwarded host), then write the Gunicorn service and the Apache virtual host, and a checklist for testing them on the server.
๐ View solutionChapter 2 Quick Reference
- A request becomes a site in three steps:
ALLOWED_HOSTS(400 if unknown), the middleware (host to site), the site's own URL configuration - The middleware sets
request.site,request.site_infoandrequest.urlconf; list it beforeCommonMiddleware request.get_host()also enforcesALLOWED_HOSTS; strip the port and lower-case before looking the host up- Each site's URL configuration is built from the site map, so the routing enforces the site boundary (
/linux/...is a 404 on the languages site) - Anchor the folder regex:
(?:/.*)?$, so/hungary-extra/does not matchhungary LW_SITEpins a process:ALLOWED_HOSTSshrinks to one name, so another site's host gets a 400- Do not rely on
*.localhost: use hosts-file entries,curl --resolveorcurl -H "Host: ..." - Behind Apache:
ProxyPreserveHost On,X-Forwarded-ProtowithSECURE_PROXY_SSL_HEADER, andUSE_X_FORWARDED_HOSTleft off - The Apache and Gunicorn configuration was not run here: test with
apache2ctl configtestandcurl -I