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:
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user