Django
Running Django here — the settings module, migrations at deploy, static files in the build and uploads kept.
Django runs on the Python image, served by Gunicorn, with no nginx in front: the platform's proxy terminates TLS and passes requests to it. What Django needs from the platform — the database, the cache, the hostnames, a secret key — is in the environment, and one settings module reads it.
The manifest
version: 1
type: django
runtime:
python: '3.14'
services:
- postgres: '18'
- valkey: '8'
build:
steps:
- name: Dependencies
run: uv sync --locked --no-dev --no-editable
- name: Static files
run: python manage.py collectstatic --noinput
cache:
- .venv
deploy:
steps:
- python manage.py migrate --noinput
on_failure: rollback
start: python -m gunicorn mysite.wsgi
workers:
- name: celery
command: python -m celery -A mysite worker --loglevel=INFO --concurrency=2
health:
path: /healthz
A Django project has a database — PostgreSQL, which a new Django project starts
on; MariaDB works as well, with mysqlclient, which the image can build. The
first deploy takes the one the file names, or keeps the environment's where
services names none. After the first release the database is fixed, and a
manifest naming the other one is refused. The cache is yours to choose or
leave out.
Run everything as python -m … or python manage.py …. The virtual
environment is built in the checkout and moved into the release, so python
is always the release's own, while a script such as bin/celery still names
the path it was built in. See Python.
The settings module
Copy this file next to your settings.py as vallic_settings.py, and at the
end of settings.py:
from . import vallic_settings
vallic_settings.apply(globals())
Off the platform it does nothing, so the same settings run on your laptop. On
it, it replaces the database, the cache, ALLOWED_HOSTS, SECRET_KEY and the
rest with what the environment says — nothing secret is committed, and nothing
differs between environments but the environment. DEBUG is off on
production whatever settings.py says. Anything you set after the call wins.
"""Vallic Cloud's settings for a Django site.
Copy this file next to your project's settings.py, and at the end of
settings.py:
from . import vallic_settings
vallic_settings.apply(globals())
It does nothing anywhere but Vallic Cloud: off the platform VALLIC_ENVIRONMENT
is not set, and your own settings stand. On it, it sets what the platform
knows and your settings cannot — the database, the cache, the hostnames, the
secret key, the proxy in front, where uploads go, and the mail relay — so
nothing secret is committed and nothing differs between environments but the
environment.
It leaves STATIC_ROOT and STORAGES to you: a build has no environment, and the
static files are collected in the build, so their settings have to be the
same there as at runtime.
"""
import hashlib
import importlib.util
import os
def _env(name, default=""):
return os.environ.get(name, default)
def apply(settings):
"""Sets what the platform knows, in a settings module's globals()."""
if not _env("VALLIC_ENVIRONMENT"):
return
# Never debug on production, whatever the settings file says: a traceback
# page is the source code and the environment, handed to whoever asks.
if _env("VALLIC_ENVIRONMENT_TYPE") == "production":
settings["DEBUG"] = False
# The secret key, derived from the environment's own entropy: generated
# once per environment and never changed, so sessions and signed values
# survive every deploy, and nothing is committed. Hashed, because the
# entropy is 44 characters and Django's checks want 50.
if _env("VALLIC_ENTROPY"):
settings["SECRET_KEY"] = hashlib.sha256(("django:" + _env("VALLIC_ENTROPY")).encode()).hexdigest()
# The database the stack runs, MariaDB or PostgreSQL.
engine = {
"mysql": "django.db.backends.mysql",
"pgsql": "django.db.backends.postgresql",
}.get(_env("DB_DRIVER"))
if _env("DB_HOST") and engine:
site = settings.get("DATABASES", {}).get("default", {})
database = {
"ENGINE": engine,
"HOST": _env("DB_HOST"),
"PORT": _env("DB_PORT"),
"NAME": _env("DB_NAME"),
"USER": _env("DB_USER"),
"PASSWORD": _env("DB_PASSWORD"),
# Kept for a minute and checked before use, so a database that
# restarted does not hand the next request a dead connection —
# unless the site's own settings say otherwise.
"CONN_MAX_AGE": site.get("CONN_MAX_AGE", 60),
"CONN_HEALTH_CHECKS": site.get("CONN_HEALTH_CHECKS", True),
}
if engine == "django.db.backends.mysql":
database["OPTIONS"] = {"charset": "utf8mb4"}
settings.setdefault("DATABASES", {})["default"] = database
# The cache, Valkey or Redis, when the stack runs one. A Redis backend the
# site chose itself — django-redis, say — is kept and pointed here; any
# other becomes Django's own, given the redis package it needs.
if _env("REDIS_HOST"):
location = "redis://%s:%s/0" % (_env("REDIS_HOST"), _env("REDIS_PORT", "6379"))
cache = settings.setdefault("CACHES", {}).get("default", {})
if "redis" in cache.get("BACKEND", "").lower():
cache["LOCATION"] = location
elif importlib.util.find_spec("redis") is not None:
settings["CACHES"]["default"] = {
"BACKEND": "django.core.cache.backends.redis.RedisCache",
"LOCATION": location,
"KEY_PREFIX": _env("VALLIC_SLUG"),
}
# Exactly the hostnames the edge routes here, and the loopback: the
# deploy's health check dials it, and asks as the loopback until a
# hostname is routed. Nothing else can reach the container.
hostnames = [hostname.strip() for hostname in _env("VALLIC_HOSTNAMES").split(",") if hostname.strip()]
settings["ALLOWED_HOSTS"] = hostnames + ["127.0.0.1", "localhost"]
if hostnames:
settings["CSRF_TRUSTED_ORIGINS"] = ["https://" + hostname for hostname in hostnames]
# The edge terminates TLS and passes the request on over plain HTTP, with
# the scheme the visitor used in X-Forwarded-Proto — which it sets itself,
# so the header can be believed.
settings["SECURE_PROXY_SSL_HEADER"] = ("HTTP_X_FORWARDED_PROTO", "https")
settings["SESSION_COOKIE_SECURE"] = True
settings["CSRF_COOKIE_SECURE"] = True
# Uploads, in the directory that outlives every release: `media/` in the
# release, linked out.
if _env("VALLIC_PUBLIC_DIR"):
settings["MEDIA_ROOT"] = _env("VALLIC_PUBLIC_DIR")
settings.setdefault("MEDIA_URL", "/media/")
# Mail through the environment's relay, which is what talks to the world.
if _env("SMTP_HOST"):
settings["EMAIL_HOST"] = _env("SMTP_HOST")
settings["EMAIL_PORT"] = 25
settings["EMAIL_USE_TLS"] = False
# Errors to stdout, which is collected, unless the site logs its own way.
settings.setdefault("LOGGING", {
"version": 1,
"disable_existing_loggers": False,
"handlers": {"console": {"class": "logging.StreamHandler"}},
"root": {"handlers": ["console"], "level": "INFO"},
})
Keep a development SECRET_KEY in settings.py. The build loads your
settings to collect static files, and a build has no environment: the module
steps aside there, and Django will not start without a key.
Two choices of yours are kept: a CONN_MAX_AGE in DATABASES, and a Redis
cache backend such as django-redis, which the module points at the stack's
Valkey rather than replacing.
Static files
Collected in the build, which can write, and served by your application from the release. WhiteNoise does the serving, with hashed, compressed names the CDN can keep indefinitely:
STATIC_ROOT = BASE_DIR / "staticfiles"
STORAGES = {
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
"staticfiles": {"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage"},
}
MIDDLEWARE.insert(1, "whitenoise.middleware.WhiteNoiseMiddleware")
These stay in your own settings rather than the module's, because the build and the running site have to agree on them, and the build has no environment.
Uploads
MEDIA_ROOT is media/ in the release, linked to a directory that outlives
every release and is in every backup, and MEDIA_URL is /media/ unless you
set it. No web server sits in front of your application to serve uploads, so
Django has to:
from django.conf import settings
from django.urls import re_path
from django.views.static import serve
# Only with somewhere to serve from. An empty MEDIA_ROOT is the current
# directory, and /media/ would hand out the project's own files.
if settings.MEDIA_ROOT:
urlpatterns += [
re_path(r"^media/(?P<path>.*)$", serve, {"document_root": settings.MEDIA_ROOT}),
]
Django calls serve a development aid rather than a file server. Behind the
CDN it is rarely asked, since the CDN keeps what it fetched; for a site whose
traffic is its uploads, attach a bucket to the environment and keep them there
with django-storages, from the S3_FILES_… variables — see
Storage.
Migrations
migrate runs as a deploy step, once the release is live and before the
health check asks. It runs on the first release too: on an empty database it is
what creates the schema. on_failure: rollback puts the previous release back if a
migration fails.
Workers, the scheduler and cron
Celery, RQ and Dramatiq are workers: the application's own image and release,
supervised, with no port. Celery's scheduler, beat, runs inside the worker with
-B, keeping its schedule where it can write:
workers:
- name: celery
command: python -m celery -A mysite worker -B --schedule-filename $VALLIC_PRIVATE_DIR/celerybeat-schedule --loglevel=INFO
Inside the worker rather than beside it, because an environment runs as many
different workers as it has machines for them — one, on a single machine — and
leave replicas at one: two copies would each send every scheduled task.
The broker can be the stack's Valkey, on a database of its own. The cache is database 0, and clearing a Redis cache empties its whole database, queue and all:
CELERY_BROKER_URL = "redis://%s:6379/1" % os.environ.get("REDIS_HOST", "localhost")
Cron runs python manage.py clearsessions every night at 03:00 by default.
Declaring any cron replaces that, so include it if you still want it:
cron:
- name: clearsessions
schedule: '0 3 * * *'
command: python manage.py clearsessions
After a restore, the platform clears every cache in CACHES.
The CDN
Pages are kept from the CDN for a visitor carrying sessionid, Django's
session cookie, so whoever is signed in sees their own. A page with a form
carries its CSRF token in the HTML, and a cached copy hands everybody the same
one, which matches nobody's cookie: mark such views private.
from django.views.decorators.cache import cache_control
@cache_control(private=True)
def contact(request):
...