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.

Everything here was run
The project in this chapter was created and tested for real, with Django 6.1.2 on Python 3.14. The scripts, test files and outputs are in the exercise solutions, and the numbers quoted below come from those runs.

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?

OptionHow it worksTrade-off
A. One project, one process, site chosen from the host nameAll 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 siteThe 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 siteEight 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.sitesConfig file (the site map)
Where the sites are definedRows in a database tableOne Python file, in the code
A fresh databaseContains a site called example.com that you must replaceNothing to set up
Host lookup1 database query (then cached)0 queries
An unknown host nameRaises Site.DoesNotExist (a server error unless caught)Gives None, and you choose the response
Copies of the site listTwo: the code and the table, which can disagreeOne

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

learning-site/ ├── manage.py ├── config/ │ ├── sites_config.py # the ONE list of sites and their folders │ ├── urls.py │ ├── wsgi.py │ └── settings/ │ ├── base.py # shared by every environment │ ├── dev.py # DEBUG on, *.localhost hosts, a throwaway key │ └── prod.py # DEBUG off, exact hosts, secure cookies, key from the environment ├── apps/ │ ├── core/ # shared helpers and middleware (Chapter 2) │ ├── content/ # pages imported from the content tree (Chapters 3 and 4) │ ├── theme/ # shared templates, static, tokens (Chapter 5) │ └── navigation/ # menus and breadcrumbs (Chapter 6) ├── templates/ ├── static/ ├── tests/ └── requirements/ ├── base.txt ├── dev.txt └── prod.txt

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.py holds what never changes; dev.py and prod.py start with from .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:

SettingValue hereWhy
SECRET_KEYDev: 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_HOSTSThe 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_ORIGINShttps:// plus each hostDjango requires the scheme in this setting.
SESSION_COOKIE_DOMAINNoneEach site keeps its own login cookie. Sharing one across subdomains is a deliberate decision (Learning Website: Framework & Architecture 9).
SECURE_HSTS_SECONDS3600Browsers 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.
# config/sites_config.py (excerpt) def site_hosts(env, only=None): names = [only] if only else list(SITES) suffix = DOMAIN if env == "prod" else "localhost" return [f"{name}.{suffix}" for name in names] # config/settings/prod.py (excerpt) SECRET_KEY = os.environ["DJANGO_SECRET_KEY"] # required: no default ALLOWED_HOSTS = site_hosts("prod", PINNED_SITE) CSRF_TRUSTED_ORIGINS = [f"https://{host}" for host in ALLOWED_HOSTS]
Two deployment warnings that stay on purpose
Django's 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

VariablePurposeRequired?
DJANGO_SETTINGS_MODULEconfig.settings.dev or config.settings.prodmanage.py defaults to dev; wsgi.py defaults to prod
DJANGO_SECRET_KEYSigning key in productionProduction only
LW_SITEPin the process to one siteNo
LW_CONTENT_ROOTWhere the content tree livesNo (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

Exercise 1

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.

📄 View solution
Exercise 2

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.

📄 View solution
Exercise 3

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.

📄 View solution

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), not django.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_ORIGINS with https://; 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
  • *.localhost may work in a browser but not in the OS resolver: use hosts-file entries or curl --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