Make the app deployable: Postgres, shared cache, S3, HTTPS

Three things would have broken a deploy, all invisible until it happened:

- psycopg was missing. dj-database-url parses postgres:// happily, so the app
  would have started and died on its first query.
- No CACHES, so Django used LocMemCache -- private to one process. waffle caches
  each flag's targeting there, so under several gunicorn workers a toggle flipped
  in the control panel flushes ONE worker and the others keep serving the stale
  flag. That is a feature that "sometimes doesn't turn on", and it makes Redis a
  requirement of the first multi-worker deploy, not of the second server.
- Uploads (club logos) sat on local disk. Fine on one box; on two, a logo
  uploaded to node A 404s on node B. Storage now switches to S3 the moment a
  bucket is configured, so adding a server stays a config change.

HTTPS behind a proxy: SECURE_PROXY_SSL_HEADER is not optional once Caddy
terminates TLS -- without it Django thinks every request is plain HTTP,
request.is_secure() is false, WebAuthn disagrees with the browser about the
origin, and SECURE_SSL_REDIRECT turns into a loop. HSTS covers subdomains,
because every club is one.

The SSL/cookie flags default to off and are switched on by the production
environment on purpose: defaulting them to `not DEBUG` would redirect every test
request to https and break the suite wherever DEBUG is unset. `check --deploy` is
what catches a deploy that forgot them.

Static files are served by WhiteNoise from the app itself, so a second app server
needs no shared volume or CDN. Manifest storage is production-only: it demands a
collectstatic manifest that no test run has.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 09:37:36 +02:00
parent 29d61eeea8
commit e5a93194bf
3 changed files with 233 additions and 1 deletions

View File

@@ -75,6 +75,10 @@ WAFFLE_FLAG_MODEL = "features.Flag"
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
# Directly after SecurityMiddleware, per WhiteNoise's contract. It serves the collected
# static files from the app itself, so no shared volume or CDN is needed to add a second
# app server.
"whitenoise.middleware.WhiteNoiseMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
@@ -209,7 +213,20 @@ USE_I18N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# Cache
#
# Redis in production, and not merely for speed: waffle caches each flag's targeting in the
# Django cache, and LocMemCache is private to one process. Under several gunicorn workers a
# toggle flipped in the control panel flushes ONE worker's cache while the others keep
# serving the stale flag — a feature that "sometimes doesn't turn on". A shared cache is the
# fix, so Redis is required from the first multi-worker deploy, not from the second server.
REDIS_URL = config("DJANGO_REDIS_URL", default="")
CACHES = {"default": {"BACKEND": "django_redis.cache.RedisCache", "LOCATION": REDIS_URL, "OPTIONS": {"CLIENT_CLASS": "django_redis.client.DefaultClient"}} if REDIS_URL else {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}}
# Static files and uploads
# https://docs.djangoproject.com/en/6.0/howto/static-files/
STATIC_URL = "static/"
@@ -219,6 +236,44 @@ STATICFILES_DIRS = [BASE_DIR / "static"]
MEDIA_URL = "media/"
MEDIA_ROOT = BASE_DIR / "media"
# Uploads (club logos) go to S3-compatible storage as soon as a bucket is configured. On one
# server the local disk works; on two, a logo uploaded to node A 404s on node B — so this is
# the switch that decides whether "add a server" is an afternoon or a migration.
AWS_STORAGE_BUCKET_NAME = config("AWS_STORAGE_BUCKET_NAME", default="")
AWS_S3_ENDPOINT_URL = config("AWS_S3_ENDPOINT_URL", default="") # set for Hetzner/Scaleway/Backblaze
AWS_S3_REGION_NAME = config("AWS_S3_REGION_NAME", default="")
AWS_ACCESS_KEY_ID = config("AWS_ACCESS_KEY_ID", default="")
AWS_SECRET_ACCESS_KEY = config("AWS_SECRET_ACCESS_KEY", default="")
AWS_S3_FILE_OVERWRITE = False
AWS_QUERYSTRING_AUTH = False # logos are public; signed URLs would break browser caching
STORAGES = {
"default": {"BACKEND": "storages.backends.s3.S3Storage"} if AWS_STORAGE_BUCKET_NAME else {"BACKEND": "django.core.files.storage.FileSystemStorage"},
# Manifest storage only in production: it demands a collectstatic manifest, and every
# {% static %} in a test would blow up without one.
"staticfiles": {"BACKEND": config("DJANGO_STATICFILES_BACKEND", default="django.contrib.staticfiles.storage.StaticFilesStorage")},
}
# HTTPS, behind a reverse proxy
#
# SECURE_PROXY_SSL_HEADER is not optional here: Caddy terminates TLS, so without it Django
# believes every request is plain HTTP. request.is_secure() goes false, allauth and WebAuthn
# disagree with the browser about the origin, and SECURE_SSL_REDIRECT becomes a loop.
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
# Off by default and switched on by the production env, deliberately: defaulting these to
# `not DEBUG` would redirect every test request to https and break the suite anywhere DEBUG
# is unset. `manage.py check --deploy` is what catches a deploy that forgot them.
SECURE_SSL_REDIRECT = config("DJANGO_SECURE_SSL_REDIRECT", default=False, cast=bool)
SESSION_COOKIE_SECURE = config("DJANGO_SESSION_COOKIE_SECURE", default=False, cast=bool)
CSRF_COOKIE_SECURE = config("DJANGO_CSRF_COOKIE_SECURE", default=False, cast=bool)
SECURE_HSTS_SECONDS = config("DJANGO_SECURE_HSTS_SECONDS", default=0, cast=int)
# Every club is a subdomain, so HSTS must cover them all or it protects only the bare domain.
SECURE_HSTS_INCLUDE_SUBDOMAINS = config("DJANGO_SECURE_HSTS_INCLUDE_SUBDOMAINS", default=True, cast=bool)
SECURE_HSTS_PRELOAD = config("DJANGO_SECURE_HSTS_PRELOAD", default=False, cast=bool)
# Phone numbers (django-phonenumber-field)