Project Layout
Learning Website with Django
Chapter 1 · Project Layout for Many Sites
Learning Website: Framework & Architecture 1 to 12 decided what the family of sites should be. This course builds it with Django, starting with the languages site. Before any page exists, one decision shapes everything that follows: how should one Django codebase serve eight sites? This chapter makes that decision, lays out the project, and proves the layout runs. You will need Python and a basic knowledge of Django (Django Fundamentals covers it); the course uses Django 6.1.
Where to Build It
The new site is its own project, separate from the folder of course content. Create the project in the
learning-website folder (or any new folder of your choice) and keep the content where it is. The
project finds the content through a setting, CONTENT_ROOT, which defaults to a
content folder beside manage.py and can point anywhere with the
LW_CONTENT_ROOT environment variable. That way the content can stay in its current home until you
decide to move it.
Decision 1: How Many Projects, How Many Processes?
| Option | How it works | Trade-off |
|---|---|---|
| A. One project, one process, site chosen from the host name | All eight sites run in the same Django process. The Host header says which site a request is for. | One thing to deploy and monitor. A bug or a crash affects every site. |
| B. One project, one process per site | The same code runs eight times, each pinned to one site. | Strong isolation and separate restarts, but eight services to manage and eight times the memory. |
| C. One Django project per site | Eight codebases. | Maximum independence, and the shared parts (design, navigation, importer) get copied eight times. Rejected. |
The layout chooses A and keeps B available. A setting called LW_SITE pins a
process to a single site. Leave it unset and one process serves every site; set it to languages and
that process answers only to languages.osztromok.com. You can start simple and split later without
changing any code. A name that is not one of the eight sites stops the start-up with a clear message, so a typo
can never serve the wrong site.
Decision 2: django.contrib.sites or a Config File?
Django ships a sites framework, and it is the obvious first thought. Exercise 3 tries it and the plain alternative side by side. The results:
| contrib.sites | Config file (the site map) | |
|---|---|---|
| Where the sites are defined | Rows in a database table | One Python file, in the code |
| A fresh database | Contains a site called example.com that you must replace | Nothing to set up |
| Host lookup | 1 database query (then cached) | 0 queries |
| An unknown host name | Raises Site.DoesNotExist (a server error unless caught) | Gives None, and you choose the response |
| Copies of the site list | Two: the code and the table, which can disagree | One |
The course uses the config file. The content comes from files, the list of sites already
exists in one place, and no database is needed to know which site a request is for. Django's own documentation
says the sites framework requires a database, and a site table you must keep in step with the code buys nothing
here. If you later add database models that belong to one site, or use a Django feature built on it (such as the
flatpages app), you can add django.contrib.sites then. Nothing in this layout prevents it.
The Layout
The skeleton is 25 small files that Exercise 1 writes for you. Three habits keep it healthy as it grows:
- One app, one job. Each app is named for the chapter that fills it in. Site-specific code (the languages site, in Chapter 7) gets its own app later, so the shared apps never fill up with
if site == "languages". - The site map is data, not logic. Settings, menus, redirects and sitemaps all read
sites_config.py. A change of mind is a one-line edit. - Settings are a package.
base.pyholds what never changes;dev.pyandprod.pystart withfrom .base import *and add only the differences.
The Settings That Matter Most
In a multi-site project a small settings mistake is expensive, because it affects every site at once. These are the ones to get right from day one:
| Setting | Value here | Why |
|---|---|---|
SECRET_KEY | Dev: a throwaway string. Prod: read from DJANGO_SECRET_KEY, with no default. | Production refuses to start without it, rather than starting with a guessable key. |
ALLOWED_HOSTS | The exact host names from the site map (for example languages.osztromok.com) | No *. Django's documentation allows a leading-dot pattern like .osztromok.com, but exact names refuse any other host. |
CSRF_TRUSTED_ORIGINS | https:// plus each host | Django requires the scheme in this setting. |
SESSION_COOKIE_DOMAIN | None | Each site keeps its own login cookie. Sharing one across subdomains is a deliberate decision (Learning Website: Framework & Architecture 9). |
SECURE_HSTS_SECONDS | 3600 | Browsers remember HSTS, so start with one hour and raise it in steps. |
SECURE_PROXY_SSL_HEADER | ("HTTP_X_FORWARDED_PROTO", "https") | Apache ends the HTTPS connection and passes the request on, so Django must trust the header it adds. |
check --deploy on the production settings is clean except for two warnings. One asks for
SECURE_HSTS_INCLUDE_SUBDOMAINS, which would tell browsers that every subdomain must use
HTTPS. Do not turn it on until every subdomain really does, including sites you have not migrated yet. The other
asks for HSTS preload, which is very hard to undo. Both are decisions for later, not oversights.
Environment Variables
| Variable | Purpose | Required? |
|---|---|---|
DJANGO_SETTINGS_MODULE | config.settings.dev or config.settings.prod | manage.py defaults to dev; wsgi.py defaults to prod |
DJANGO_SECRET_KEY | Signing key in production | Production only |
LW_SITE | Pin the process to one site | No |
LW_CONTENT_ROOT | Where the content tree lives | No (default: content/ beside manage.py) |
Secrets never go in the repository: .env is in .gitignore, and the production key comes from
the server's environment (Learning Website: Framework & Architecture 8). A key committed to git has to be
treated as public.
A Caveat About *.localhost
The dev settings answer to languages.localhost, systems.localhost and so on, so you can
run the sites on your own machine under their real-looking names. Django's own default for debug mode also
accepts .localhost. Most current browsers send any name ending in .localhost to your
own computer. But the operating system's resolver may not: on the Windows machine used to write this chapter,
Python's socket.gethostbyname("languages.localhost") failed even though localhost itself
worked. So a browser may work while curl or a script fails. Django's test client sends the host name
itself (HTTP_HOST), so the tests are unaffected. Chapter 2 deals with local development in detail,
including hosts-file entries and curl --resolve.
Start With Tests
The settings are covered by tests from the first day (Exercise 2): every folder belongs to one site, hosts are
exact names with no wildcard, a pinned site gives exactly one host, an unknown site name is rejected, and the
production settings have an https origin for each host and refuse to load without a secret key. The ten tests
run in a few hundredths of a second. One of them failed on its first run, because Django's test runner adds
testserver to ALLOWED_HOSTS; the fix was to leave that name out of the comparison, not
to loosen the test.
Hands-On Exercises
Create the project layout from this chapter as real files with a script, in a virtual environment with Django 6.1 installed. Run manage.py check, then again with LW_SITE=languages and with a nonsense value, and say what each shows.
Write tests for the site map and the settings (unique folders, exact hosts, pinning, https origins, the secret key), run them, and then run check --deploy on the production settings. Fix what you can and explain any warning you leave.
Compare django.contrib.sites with a plain config lookup: migrate a fresh database, add the eight sites, find the site from a request host with each method, try an unknown host, and count database queries. Decide which to use and why.
Chapter 1 Quick Reference
- One project, one codebase; one process for all sites by default, or pin a process to one site with
LW_SITE - Sites come from one config file (
config/sites_config.py), notdjango.contrib.sites: 0 queries, one copy of the list, and unknown hosts are yours to handle - Layout:
config/(settings package, site map, URLs),apps/(core, content, theme, navigation),templates/,static/,tests/,requirements/ - Settings: no default secret key in production; exact
ALLOWED_HOSTS;CSRF_TRUSTED_ORIGINSwithhttps://;SESSION_COOKIE_DOMAIN = None - HSTS: start at 3600 seconds; leave include-subdomains and preload off until every subdomain is on HTTPS
- Environment variables:
DJANGO_SETTINGS_MODULE,DJANGO_SECRET_KEY,LW_SITE,LW_CONTENT_ROOT *.localhostmay work in a browser but not in the OS resolver: use hosts-file entries orcurl --resolve(Chapter 2)- Create the virtual environment in a short path on Windows (the 260-character limit broke the Django install)
- Test the settings from day one; when a test fails, read the difference before changing either side