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.

Run for real, on Django 6.1
The code in this chapter was added to the Chapter 1 project, covered by 24 tests (all passing), and tried with the real development server and 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

browser asks for https://languages.osztromok.com/hungary/hungarian-basic-3/ 1. DNS languages.osztromok.com -> the server's address 2. Apache picks the virtual host by name, ends HTTPS, passes the request on 3. Gunicorn runs Django (one process for all sites, or one pinned to a site) 4. Django ALLOWED_HOSTS must contain the host name (otherwise 400) 5. Middleware host name -> site "languages" (request.site) 6. URL config the languages site's own routes (request.urlconf) 7. View builds the page for that path

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:

class SiteMiddleware: def __call__(self, request): host = request.get_host().split(":")[0].lower() # get_host() enforces ALLOWED_HOSTS site = settings.HOST_TO_SITE.get(host) if site is None: return HttpResponseNotFound("Unknown site", content_type="text/plain") request.site = site # "languages" request.site_info = SITES[site] # title and folders request.urlconf = f"config.urlconfs.{site}" # this site's routes return self.get_response(request)
  • request.get_host() does two jobs. It returns the host name, and it checks it against ALLOWED_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:8000 and languages.localhost are the same site.
  • The lookup table comes from the site map. HOST_TO_SITE is built by one function in sites_config.py, host_map(env), so the settings and the middleware cannot disagree.
  • Order matters. The middleware must be listed before CommonMiddleware, which reads request.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.

def build(site): folders = "|".join(re.escape(f) for f in SITES[site]["folders"]) module = types.ModuleType(f"config.urlconfs.{site}") module.urlpatterns = [ re_path(r"^$", views.home, {"site": site}, name="home"), re_path(rf"^(?P<page_path>(?:{folders})(?:/.*)?)$", views.page, {"site": site}, name="page"), ] sys.modules[module.__name__] = module # so Django can import it by name return module

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).

Anchor the folder pattern
The pattern ends with (?:/.*)?$, 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”

RequestResultWho answers
A host name that is not in ALLOWED_HOSTS400Django, 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 have404That site's URL configuration
A known site, a page that does not exist404The 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:

RequestAll-sites processPinned to languages
languages.localhost, path /200200
languages.localhost, path /hungary/x/200200
systems.localhost, path /hungary/x/404 (no such route on that site)400 (host not allowed)
Host evil.example.org400400
Host 127.0.0.1:8765 (no proxy host name)400400

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.

OptionHow
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 --resolvecurl --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 knowHow the proxy tells itDjango setting
The host name the visitor usedApache: ProxyPreserveHost OnNone (the default: use the Host header)
Whether the visitor used HTTPSApache: RequestHeader set X-Forwarded-Proto "https"SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
Which headers to trustTrust as few as possibleLeave USE_X_FORWARDED_HOST off
  • Without ProxyPreserveHost On Django 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-Proto by default and believes it once SECURE_PROXY_SSL_HEADER is set. That is also what stops SECURE_SSL_REDIRECT from looping: Django finally sees that the request was HTTPS.
  • USE_X_FORWARDED_HOST is a trap. With it on, a visitor can pick the site by sending an X-Forwarded-Host header. A test shows a request addressed to languages.localhost being 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

Exercise 1

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.

๐Ÿ“„ View solution
Exercise 2

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.

๐Ÿ“„ View solution
Exercise 3

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 solution

Chapter 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_info and request.urlconf; list it before CommonMiddleware
  • request.get_host() also enforces ALLOWED_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 match hungary
  • LW_SITE pins a process: ALLOWED_HOSTS shrinks to one name, so another site's host gets a 400
  • Do not rely on *.localhost: use hosts-file entries, curl --resolve or curl -H "Host: ..."
  • Behind Apache: ProxyPreserveHost On, X-Forwarded-Proto with SECURE_PROXY_SSL_HEADER, and USE_X_FORWARDED_HOST left off
  • The Apache and Gunicorn configuration was not run here: test with apache2ctl configtest and curl -I