How-to

Python applications

Run a FastAPI (ASGI) application with uvicorn or a Django (WSGI) application with gunicorn behind ScalWS.

Which way?

Server unitHow the application runsSite file key
scalwsd.serviceScalWS builds the uvicorn or gunicorn command line, starts and supervises it.python
scalws-controller (default)You run uvicorn or gunicorn yourself (for example as a systemd unit). ScalWS proxies to it.proxy

scalws-controller refuses python runtimes, also python with upstream: (ADR-0036).

Prerequisites

  • ScalWS installed, with include: sites.d/*.yaml in /etc/scalws/scalws.yaml.
  • The project outside /home, for example /srv/api, with a virtualenv that contains the server: uvicorn for ASGI, gunicorn for WSGI.
python3 -m venv /srv/api/.venv
/srv/api/.venv/bin/pip install -r /srv/api/requirements.txt uvicorn

FastAPI with uvicorn (scalwsd.service)

# /etc/scalws/sites.d/api.example.com.yaml
domain: api.example.com
root: /srv/api
profile: fastapi
python:
  mode: asgi
  app: "main:app"
  virtualenv: /srv/api/.venv
  workers: {min: 2, max: 8}
https_redirect: true

ScalWS runs /srv/api/.venv/bin/uvicorn main:app --uds <socket> --workers 2 --no-access-log in /srv/api.

  • python.mode: asgi or wsgi (required).
  • python.app: module:callable.
  • python.virtualenv: the server binary comes from <virtualenv>/bin; VIRTUAL_ENV and PATH are set.
  • python.workers: uvicorn’s own worker count is min. With process_pool: true ScalWS runs min to max single-worker servers itself instead, which it can scale.
  • profile: fastapi sets the readiness check to /docs and bypasses the cache for /docs and /openapi.json. If your application disables /docs, set python.health_path to a path it serves.

The short form python: "main:app" (also scalwsctl site add --python main:app) runs uvicorn from the system path with its defaults.

Django with gunicorn (scalwsd.service)

# /etc/scalws/sites.d/example.org.yaml
domain: example.org
www: redirect
root: /srv/mysite
profile: django
python:
  mode: wsgi
  app: "mysite.wsgi:application"
  virtualenv: /srv/mysite/.venv
  process_pool: true
  workers: {min: 2, max: 8, autoscale: auto, target_concurrency: 1}
routes:
  - path: /static
    runtime: {type: static, root: /srv/mysite/public}
  • WSGI with app needs python.server gunicorn, the default for wsgi.
  • Route paths are not stripped: with STATIC_ROOT = "/srv/mysite/public/static" the route’s root is /srv/mysite/public. Run manage.py collectstatic after each deploy.
  • profile: django denies /manage.py and /db.sqlite3 and bypasses the cache for /admin and the sessionid and csrftoken cookies.
  • Add the domains to Django’s ALLOWED_HOSTS.

The process gets FORWARDED_ALLOW_IPS=* and PYTHONUNBUFFERED=1. It runs inside the unit’s sandbox, where /srv is read-only; give write access (media uploads, a SQLite file) with a ReadWritePaths= drop-in for scalwsd.service.

Under scalws-controller: proxy to your own service

Run the server on loopback with systemd, for example for FastAPI:

# /etc/systemd/system/api-example-com.service
[Unit]
Description=api.example.com
After=network.target

[Service]
User=api
WorkingDirectory=/srv/api
ExecStart=/srv/api/.venv/bin/uvicorn main:app --host 127.0.0.1 --port 8000 --workers 4 --proxy-headers --forwarded-allow-ips 127.0.0.1
Restart=always

[Install]
WantedBy=multi-user.target

For Django the ExecStart= line would be /srv/mysite/.venv/bin/gunicorn mysite.wsgi:application --bind 127.0.0.1:8000 --workers 4.

sudo systemctl daemon-reload
sudo systemctl enable --now api-example-com
sudo scalwsctl site add api.example.com --proxy http://127.0.0.1:8000
# /etc/scalws/sites.d/api.example.com.yaml
domain: api.example.com
proxy: http://127.0.0.1:8000

The proxy replaces X-Forwarded-For and X-Forwarded-Proto with the values it saw, so the application can trust them from 127.0.0.1 (uvicorn --forwarded-allow-ips, Django SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")). Serve Django’s static files with a static route as above.

Check it works

curl -sI -H 'Host: api.example.com' http://127.0.0.1/docs
sudo scalwsctl site list
scalwsctl apps                       # under scalwsd.service
journalctl -u scalwsd -o cat | jq -c 'select(.target == "scalws::app")'

Common problems

  • mode missing: python: as a map needs mode: asgi or mode: wsgi.
  • No worker becomes ready (503): the server is not installed in the virtualenv, the module:callable is wrong, or health_path answers 5xx. The server’s output is in the journal with target scalws::app.
  • managed python runtimes are not supported with several instances: use the proxy setup under scalws-controller, or switch to scalwsd.service.
  • Static files 404 under /static: the route root must be the directory that contains static/, not static/ itself.
  • Django answers 400 Bad Request: the domain is missing from ALLOWED_HOSTS.
  • A custom command (extra server options): use python.command, for example command: [/srv/api/.venv/bin/uvicorn, "main:app", --proxy-headers] together with mode; ScalWS appends the bind arguments.

See also: applications, FastAPI and Django examples.