# Django

> Running Django here — the settings module, migrations at deploy, static files in the build and uploads kept.

Django runs on the [Python](stack-python.md) 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

```yaml
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](framework-python.md).

## The settings module

Copy this file next to your `settings.py` as `vallic_settings.py`, and at the
end of `settings.py`:

```python
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.

```python
"""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](https://whitenoise.readthedocs.io/) does the serving,
with hashed, compressed names the CDN can keep indefinitely:

```python
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:

```python
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](storage.md).

## 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:

```yaml
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:

```python
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:

```yaml
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.

```python
from django.views.decorators.cache import cache_control

@cache_control(private=True)
def contact(request):
    ...
```
