Deployment and Performance

In development, proper run starts a server, reloads it when you save a file, and nothing else needs thinking about. In production the questions change: which Python runs the app, how many requests it works on at once, how many database connections that opens, what sits in front of it, and where the background tasks run. Get these wrong and the app either wastes the machine or falls over under load.

Proper answers most of them with defaults and with the files a new app already has: a Dockerfile, a compose.yml, an nginx config, and an .env.example with the variables the production config reads. This guide explains what those files do and what each setting changes, so you can adjust them with a reason.

After reading this guide, you will know:

  • Why Proper serves on free-threaded Python, and what happens when it isn't.
  • What proper run starts, and what WORKERS, MAX_THREADS and PROCESSES control.
  • How to size those settings for your machine.
  • Which environment variables and settings matter in production.
  • How the Docker image is built, and how one image runs the web server, the worker and the migrations.
  • How to put nginx in front of the app, including the WebSocket port.
  • What to do, in order, on each deploy.
  • How Proper performs compared to other Python, Ruby, Go and Rust frameworks.

1. Linux

Proper runs on Linux. On Windows, use WSL; on macOS, run the app in a container, like the one the blueprint's Dockerfile builds.


2. Free-threaded Python

Proper serves on free-threaded Python, the "t" builds of CPython: 3.14t today. On those builds there is no GIL, so the threads of one process run Python code in parallel. That lets Proper run its server workers as threads that share one interpreter and one copy of the app, instead of one process per worker with a copy of everything in each.

The trade-off is measured: free-threaded Python is 5 to 10% slower per thread than the GIL build, and it uses much less memory for the same number of concurrent requests. Python 3.15 keeps free-threading as a separate build too, so this is not a temporary arrangement.

Install it with uv:

uv python install 3.14t

A new app is already set up for it. The blueprint pins the interpreter in .python-version and allows any 3.x from 3.14 in pyproject.toml:

.python-version
3.14t
pyproject.toml
requires-python = ">=3.14,<4"

2.1 When the GIL is on

proper run checks the interpreter before it starts. On a Python with the GIL it refuses:

[ERROR] This Python has the GIL. Proper serves on free-threaded Python: install one with `uv python install 3.14t` and run the app with it.
To serve with the GIL anyway, set ALLOW_GIL = True in the config.

A free-threaded Python can also turn the GIL back on by itself: when it imports an extension module that has not declared itself safe without the GIL, it re-enables it and names the module in a RuntimeWarning. proper run notices and stops with a message pointing at that warning. The fix is to upgrade or replace that module.

If you can't, set ALLOW_GIL = True in the config. proper run then serves with the GIL and prints a warning at startup. The app works, but the threads no longer run in parallel, so you lose throughput, and you will need more processes (and more memory) to get it back.


3. The server

proper run starts Bearings, a server written in Free Pascal, with your app:

proper run [--host 0.0.0.0] [--port PORT] [--workers N]

--host defaults to 0.0.0.0; --port and --workers default to the PORT and WORKERS settings. Everything else comes from the config.

Bearings reads and writes the network on a few threads of its own, and runs each request on one thread of a pool. Proper controllers are sync, so that thread runs the request to the end, with no event loop and no hand-off.

PORT is the port the server binds to: 2300 in the blueprint, read from the PORT environment variable. Don't confuse it with HOST, which is the public base URL of the app ("YOUR-DOMAIN.com" in production) and is only used to build absolute URLs.

3.1 Workers, threads and processes

Three settings decide how much work the server does at once.

  • WORKERS is the number of threads that read and write the network in each process. They never run your code, so one is enough for most apps.
  • MAX_THREADS is how many threads run your code in each process. Each request holds one thread from start to finish, so this is how many requests the app works on at once. 0 means min(32, cpu_count + 4).
  • PROCESSES is the number of copies of the web server proper run starts, all on the same port (the operating system spreads connections between them with SO_REUSEPORT).

MAX_THREADS has a second meaning worth keeping in mind: each thread opens its own database connection, so it is also how many connections a process can hold.

Why more than one process, if threads already run in parallel? Threads of one interpreter still contend for the objects they share. On a machine with four or more cores, two smaller groups of threads do better than one big one: a second process adds about 10% throughput at 16 threads on a 10-core desktop. The price is a second copy of the app in memory.

3.2 The cable

WSGI has no WebSockets. When the app uses channels, they are served on CABLE_PORT, which the channels addon sets to PORT + 1 (2301), from the CABLE_PORT environment variable if set. The channels addon configures Cable: the web process itself serves them, with Bearings, started by proper run before the first request, so no second process is needed. The other processes that load the app (the extra PROCESSES, the task worker) forward their broadcasts to it over 127.0.0.1:CABLE_PORT + 1.

An app without channels has CABLE = {} and CABLE_PORT = 0, the defaults: no WebSockets. Setting CABLE_PORT without a CABLE that serves them is a configuration error.

In production, the reverse proxy routes CABLE_PATH (default /cable) to that port, with the WebSocket upgrade headers; the nginx config below has that block. In development there is no proxy: when DEBUG is on, render_importmap() adds a <meta name="cable-port"> tag to the page, and cable.js connects to that port on the same hostname.

Broadcasts made in a process without the WebSockets are forwarded as a signed POST to CABLE_PATH. If the process that serves them is down, the message is lost and a warning is logged. On several machines, use RedisCable; see Several machines in the Channels guide.

3.3 Reloading and stopping

RELOAD restarts the server when a file under the app changes. The default, None, follows DEBUG: on in development, off in production. The restart is done by a supervisor outside the server and covers the whole group - every web process, and the cable in the first one.

Ctrl+C, or a SIGTERM to proper run, shuts down every process it started.

3.4 Compiled views

The views are compiled to Python modules under _compiled/views/, next to your app's package. The server compiles them all when it starts, in a few milliseconds. Outside of DEBUG, a view that does not compile stops the start, listing every error, instead of failing its first request. In DEBUG, the errors are logged and the server starts anyway: each error shows when its view is rendered, and a view you edit is compiled again on the next request that uses it. The folder is in the .gitignore of new apps; COMPILED_PATH in the config moves it.

You can also compile them before the app starts, with proper jx compile. Outside of DEBUG, the server does not compile again the views that are already compiled: it only checks that no view is newer than its module. So an app compiled when it is built starts faster, does not need to write to that folder, and a view that does not compile stops the build instead of the start. The Dockerfile of new apps does it:

RUN APP_ENV=dev .venv/bin/proper jx compile

It runs with the development config because the production one usually needs secrets, like SECRET_KEYS, that must not be in the image. The compiled views are the same in both, unless the config changes TEMPLATE_TAGS by environment; in that case, the views are compiled again when the app starts.

The user that runs the app must be able to write to _compiled when the views were not compiled before, or when one has to be compiled again.


4. Sizing

These are starting points, not rules. Measure with your own pages before changing them.

  • Start with WORKERS=1 and PROCESSES=1. That is right for a small server.
  • On four or more cores, try WORKERS=4.
  • If you have memory to spare, add PROCESSES=2, and check that your pages get faster.
  • Keep MAX_THREADS at its default unless the number of database connections is what limits you. Remember that each thread is a connection, and with several processes, each process has its own MAX_THREADS.
  • SQLite allows one writer at a time. More threads help reads, not writes.
Setting Default Blueprint reads it from What it controls
PORT 2300 PORT The port the server binds to
WORKERS 1 WORKERS Threads that read and write the network, per process
MAX_THREADS 0 (min(32, cpus + 4)) - Threads running your code per process; also database connections
PROCESSES 1 PROCESSES Copies of the web server on the same port
CABLE_PORT 0 (none) CABLE_PORT (channels addon) Port of the WebSockets (Cable)
CABLE_PATH "/cable" - URL path the proxy routes to CABLE_PORT
RELOAD None (follows DEBUG) - Restart on code changes
ALLOW_GIL False - Serve on a Python with the GIL

5. Production configuration

The blueprint's config files read APP_ENV and switch branches on it: dev by default, test for the test suite, and prod in production. The Docker image sets APP_ENV=prod. In the prod branches, DEBUG is off, PROTOCOL is "https", the database is Postgres, the queue is Redis, and the mailer is SMTP.

Before your first deploy, edit HOST in config/main.py to your domain:

config/main.py
if env == "prod":
    PROTOCOL = "https"
    HOST = "YOUR-DOMAIN.com"

5.1 Environment variables

The prod branches read their secrets and connection settings from the environment. .env.example lists all of them; copy it to .env and fill it in.

The app reads that .env file by itself when it starts (load_dotenv() at the top of config/main.py), so it also works outside Docker - in development, for the variables you'd otherwise export in your shell. A variable that is already in the environment is never replaced by the one in the file, and the file is git-ignored.

Variable What it is
SECRET_KEYS Comma-separated secret keys, oldest to newest, each 48+ random characters
DB_HOST, DB_PORT, DB_NAME, DB_USER, DB_PASSWORD The Postgres database
REDIS_HOST, REDIS_PORT, REDIS_NAME Redis, for the task queue
SMTP_USERNAME, SMTP_PASSWORD The SMTP mailer
WEB_PORT Host port Compose publishes the web server on (the container always listens on 2300)
WORKERS, PROCESSES See Sizing

New values are signed with the newest key, the last in the list, and every key in SECRET_KEYS is accepted when reading, so you can rotate them: append a new key, and once everything signed with the oldest has expired, remove it. Never deploy with an empty SECRET_KEYS.

5.2 Databases

The default database class is peewee.SqliteDatabase, which is fine for a single server with modest write traffic. The blueprint's prod branch in config/storage.py switches to Postgres through a connection pool:

config/storage.py
if env == "prod":
    DATABASES["main"] = {
        "type": "playhouse.postgres_ext.PooledPsycopg3Database",
        "database": os.getenv("DB_NAME", "myapp"),
        "host": os.getenv("DB_HOST", "127.0.0.1"),
        "port": int(os.getenv("DB_PORT", 5432)),
        "user": os.getenv("DB_USER", "root"),
        "password": os.getenv("DB_PASSWORD", ""),
    }

psycopg needs the libpq5 system library, which the Docker image installs. If you keep SQLite in production, put the database file under storage/ so it lives on a volume.

5.3 Letting the proxy send files

STATIC_X_SENDFILE_HEADER hands file responses to the proxy: the app returns a header naming the file and nginx sends it, so a large download doesn't hold one of your threads. The blueprint sets it to "X-Accel-Redirect", the nginx header, in production; Apache and lighttpd use "X-Sendfile". See Assets and Storage.


6. Docker

The blueprint's Dockerfile builds a two-stage image on debian:bookworm-slim. The official python Docker images have no free-threaded variant, so uv installs the interpreter itself. A trimmed version:

Dockerfile
ARG PYTHON_VERSION=3.14t
ARG UV_VERSION=0.11.7
FROM ghcr.io/astral-sh/uv:${UV_VERSION} AS uv

# --- Stage 1: builder
FROM debian:bookworm-slim AS builder
COPY --from=uv /uv /uvx /bin/
ENV UV_COMPILE_BYTECODE=1 \
    UV_LINK_MODE=copy \
    UV_PYTHON_INSTALL_DIR=/python \
    UV_PYTHON_PREFERENCE=only-managed \
    UV_PYTHON=3.14t
RUN uv python install 3.14t

WORKDIR /app
COPY pyproject.toml uv.lock README.md ./
RUN uv sync --frozen --no-install-project --no-dev
COPY . .
RUN uv sync --frozen --no-dev

# --- Stage 2: runtime
FROM debian:bookworm-slim AS runtime
COPY --from=builder /python /python
ENV APP_ENV=prod PORT=2300 \
    VIRTUAL_ENV=/app/.venv PATH="/app/.venv/bin:$PATH"
RUN apt-get update \
    && apt-get install --no-install-recommends -y ca-certificates libpq5 libvips \
    && rm -rf /var/lib/apt/lists/* \
    && groupadd --system app \
    && useradd --system --gid app --home-dir /app --no-create-home app
WORKDIR /app
COPY --from=builder --chown=app:app /app /app
RUN mkdir -p storage log && chown -R app:app storage log
USER app

EXPOSE 2300 2301
HEALTHCHECK --interval=30s --timeout=3s --start-period=20s --retries=3 \
    CMD python -c "import os,urllib.request; urllib.request.urlopen('http://127.0.0.1:%s/up' % os.environ.get('PORT','2300'), timeout=2)" || exit 1
CMD ["proper", "run"]

What each part does:

  • The builder installs 3.14t into /python, then installs the dependencies in their own layer, so Docker reuses that layer until pyproject.toml or uv.lock change. Only then does it copy your code and install the project.
  • The runtime stage copies the interpreter and /app (code and virtualenv) from the builder. /python is the same path in both stages, so the virtualenv's links to the interpreter keep working. It adds libpq5 for Postgres and libvips for image processing; the full file has commented lines for poppler-utils and ffmpeg if you preview PDFs or videos.
  • The app runs as app, an unprivileged user. storage/ and log/ are its writable directories; mount volumes there.
  • The health check requests the app's /up route, which every new app has. With channels, app.cable.health() tells how the WebSockets are doing, and Prometheus can scrape 127.0.0.1:CABLE_PORT + 1/metrics from the web process (see Channels).

Build it with:

docker build -t myapp:latest .

The same image runs everything. Only the command changes:

docker run ... myapp:latest                     # web server: proper run
docker run ... myapp:latest python workers.py   # background worker
docker run --rm ... myapp:latest proper db migrate

Running the worker from the same image as the web server matters: both import exactly the same tasks and models, so a task enqueued by one version is never run by another. The blueprint also has a Dockerfile.workers, which builds on the app image and only changes the command and disables the HTTP health check, for when you need to push a separate worker image to a registry.


7. Compose and the reverse proxy

7.1 Compose

compose.yml runs the app with Postgres and Redis managed elsewhere, reached through the variables in .env:

compose.yml
x-app: &app
  image: myapp:latest
  build:
    context: .
    dockerfile: Dockerfile
  env_file:
    - .env
  environment:
    APP_ENV: prod
  restart: unless-stopped

services:
  web:
    <<: *app
    ports:
      - "${WEB_PORT:-2300}:2300"
      - "${CABLE_PORT:-2301}:2301"
    volumes:
      - storage:/app/storage

  worker:
    <<: *app
    command: ["python", "workers.py"]
    healthcheck:
      disable: true
    volumes:
      - storage:/app/storage

  migrate:
    <<: *app
    command: ["proper", "db", "prepare"]
    restart: "no"
    profiles: ["tools"]

volumes:
  storage:
  • web publishes the web server and the cable port, and mounts the storage volume for uploads and the cache file.
  • worker is the same image running python workers.py, with the health check turned off because it serves no HTTP.
  • migrate is a one-off job behind the tools profile, so docker compose up never starts it. It runs proper db prepare, which runs proper db migrate for every database and then proper db seed.

The order is:

cp .env.example .env              # then fill in the secrets and hosts
docker compose build
docker compose run --rm migrate   # apply migrations once
docker compose up -d web worker

7.2 The reverse proxy

The app should not face the internet directly. Put nginx (or another proxy) in front of it to terminate TLS, serve the static assets from disk, and route the WebSockets. The blueprint's deploy/nginx.conf has the whole server block, with the TLS lines commented out. Its locations:

deploy/nginx.conf
# The largest request body nginx lets through
client_max_body_size 8M;

# Fingerprinted assets: remove the hash and serve the file from disk
location ~* "^/assets/(.*)-[a-f0-9]{12,}\.([a-z0-9]+)$" {
  root /var/www/myapp;
  try_files /assets/$1.$2 =404;
  add_header Cache-Control "public, max-age=31536000, immutable";
}

# The other assets
location /assets/ {
  alias /var/www/myapp/assets/;
  add_header Cache-Control "public, max-age=0, must-revalidate";
}

# WebSockets: the cable, on CABLE_PORT
location /cable {
  proxy_pass http://127.0.0.1:2301;
  proxy_http_version 1.1;
  proxy_set_header Upgrade $http_upgrade;
  proxy_set_header Connection "upgrade";
  proxy_set_header Host $host;
  proxy_set_header X-Real-IP $remote_addr;
  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
  proxy_set_header X-Forwarded-Proto $scheme;
  proxy_read_timeout 1h;
}

# Everything else: the web server
location / {
  proxy_pass http://127.0.0.1:2300;
  proxy_set_header Host $host;
  proxy_set_header X-Real-IP $remote_addr;
  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
  proxy_set_header X-Forwarded-Proto $scheme;
}

error_page 500 502 503 504 /500.html;
location = /500.html {
  root /var/www/myapp/assets;
}

The /cable block needs proxy_http_version 1.1 and the Upgrade and Connection headers to pass the WebSocket handshake through, and a long proxy_read_timeout so nginx doesn't close idle connections. Remove the block if the app has no channels. If you change CABLE_PATH, change the location to match.

The two /assets/ blocks send the same Cache-Control headers the app sends when it serves the assets itself: a fingerprinted file can be kept by the browser for a year, and the others are revalidated on each use. Both paths assume the project is in /var/www/myapp; note that the alias must end with a slash, like its location.

client_max_body_size is the largest request nginx accepts, and its default is only 1 MB. Keep it in sync with MAX_CONTENT_LENGTH (8 MB by default): if it's lower, nginx answers 413 to uploads that the app would have accepted.

The 500.html page is served by nginx, so visitors see it even when the app is down.

7.3 The Host header

request.host and request.port come from the Host header, as PEP 3333 rebuilds the URL; only a request without one (HTTP/1.0) gets the address the server listens on, which under proper run is 0.0.0.0 or the Unix socket. Routes with a host, and OriginProtection, compare with it. So the proxy must pass the client's Host through, as proxy_set_header Host $host; does in the nginx example: by default nginx sends the address in proxy_pass (127.0.0.1:2300), and the routes for a host then answer 404. An invalid Host (example.com/x, a space, user@example.com, brackets around something that is not an IPv6 address such as [abc]) gets a 400, and so does a request with more than one Host line, as RFC 9112 requires: the server joins the lines with a comma, so a Host with a comma is refused.

The client chooses the Host header, so the app never builds a URL from it: url_for(..., _full=True), the password-reset email and the storage URLs use PROTOCOL and HOST from the config, or the host of the route. Set HOST to your public host name, and build the absolute URLs that leave the request (emails, links you store or send to others) with url_for(..., _full=True), not from request.host. X-Forwarded-Host and Forwarded: host= are not read.

7.4 Trusted proxies

The X-Real-IP, X-Forwarded-For and X-Forwarded-Proto headers tell the app who the visitor is and whether they connected over HTTPS: request.remote_ip comes from them, and so does request.protocol. request.protocol decides request.is_secure, the default port in request.host_with_port, and whether the session and auth cookies are Secure. Any client can send those headers too, so the app uses them only when the request comes from one of the TRUSTED_PROXIES. From any other address they are ignored, and request.remote_ip is the address the request came from.

The default list is the loopback, private and link-local networks: 127.0.0.0/8, ::1, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16, fc00::/7 and fe80::/10. That covers nginx on the same machine, in another container, or on the same private network. If your proxy or load balancer connects to the app from another address, list it in config/main.py. The list replaces the default one, so keep the ranges you still need:

config/main.py
TRUSTED_PROXIES = ["127.0.0.1", "203.0.113.0/24"]

Each item is an IP address or a network in CIDR notation, as a string or an ipaddress object; anything else (an int, bytes, a host name) stops the app at startup with a ConfigError. An empty list ignores the forwarding headers from every address, for an app that faces the internet directly.

The default trusts your whole private network

With the default list, every loopback, private and link-local address counts as a proxy. A client that reaches the app, or the first proxy, from one of those addresses is believed: another machine on the same LAN or VPC, a container on the same host, or any visitor behind a load balancer that replaces the source address with its own private one (SNAT). Such a client can choose its request.remote_ip with X-Forwarded-For or X-Real-IP, and make the request secure with X-Forwarded-Proto: https. If clients can connect from those networks, replace the default with the addresses of your proxies only.

The proxy must overwrite X-Forwarded-Proto with the scheme it got the request on, as proxy_set_header X-Forwarded-Proto $scheme; does in the nginx example: the app uses the whole value of the header, so a proxy that appends to it (https, http) or passes the client's header through lets the client decide whether the request is secure.

proper run or gunicorn (--bind unix:...) listening on a Unix socket instead of a port gives the app no address for the peer. Only a process on the same machine can connect to the socket, so such a request counts as coming from a trusted proxy. Some other servers give a made-up address instead (waitress gives localhost, uWSGI 0.0.0.0), which is not trusted: there, every request gets the same remote_ip and is not secure.

Each proxy adds the address it got the request from at the end of X-Forwarded-For, so the app reads the list from the right and skips the trusted proxies: remote_ip is the first address that is not one, without a port if the proxy added one (and an IPv4-mapped address, ::ffff:203.0.113.5, as 203.0.113.5). Whatever is further left was written by the client or an untrusted hop. X-Forwarded-For comes first, then the standard Forwarded header, read the same way (its for= values, without port or brackets), then X-Real-IP. A header sent in several lines counts as one list, the lines in the order they came. A proxy passes the headers it doesn't write through as the client sent them, so make yours remove the ones that come before the one it writes: if it writes only Forwarded, make it remove X-Forwarded-For; if it writes only X-Real-IP, make it remove X-Forwarded-For and Forwarded.

The proxy must also drop the request headers with an underscore in the name, such as X_Forwarded_For. The WSGI server gives the app X-Forwarded-For and X_Forwarded_For under the same name, so the app can't tell the client's header from the proxy's: proper run drops the headers with an underscore, but other WSGI servers keep one of the two values, and it can be the client's. A client could then choose remote_ip and, with X_Forwarded_Proto: https, make the request secure. nginx drops these headers unless underscores_in_headers is on or ignore_invalid_headers is off, so keep both at their defaults (underscores_in_headers off, ignore_invalid_headers on). A proxy built on Go's httputil.ReverseProxy passes them through; check yours.

Every proxy in the chain must be in the list, also the ones that never connect to the app. With a CDN (Cloudflare, CloudFront) or a public load balancer in front of nginx, X-Forwarded-For arrives as <visitor>, <CDN edge>: nginx is trusted, but the CDN's address is not, so remote_ip is the CDN's address for every visitor. Everything that uses remote_ip then sees one client per CDN edge: the default rate_limit (one counter for all of them, so they all get 429s together) and the IP address stored with each login session. Add the CDN's published address ranges to TRUSTED_PROXIES, along with the default ranges you still need:

config/main.py
TRUSTED_PROXIES = [
    "127.0.0.0/8", "::1", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16",
    # Your CDN's ranges, from its documentation, e.g. Cloudflare's:
    "173.245.48.0/20", "103.21.244.0/22",  # ...
]

8. Background workers

Tasks are run by the Huey consumer, a separate process started with python workers.py from the same image. It reads its options from QUEUE_CONSUMER in config/storage.py. The ones that matter for deployment:

Option What it is
workers How many tasks run at once
worker_type "thread" (default), "process" or "greenlet"
graceful_signal The signal that stops the consumer after the running tasks finish ("TERM" by default)
shutdown_timeout Seconds to wait for running tasks on a graceful stop; None (the default) waits

graceful_signal is "TERM" because docker stop and most supervisors send SIGTERM. With it, a deploy lets the running tasks finish instead of killing them halfway.

Whether you need the worker at all depends on the queue. With huey.MemoryHuey and immediate: True, the blueprint's default outside prod, tasks run inline in the web process and no worker is needed. With SqliteHuey or RedisHuey (the blueprint uses Redis in prod), tasks wait in the queue until a worker runs them; if no worker is running, they never run. The Background Tasks guide covers the backends, retries and periodic tasks.


9. Deploy checklist

On each deploy, in this order:

  1. Build the new image. docker compose build, or docker build -t myapp:latest ..
  2. Check the pending migrations. proper db todo lists the ones not yet applied to the main database.
  3. Migrate before starting the new version. docker compose run --rm migrate, or proper db migrate. The old version keeps serving while this runs, so write migrations the old code can live with: add columns before using them, remove them after the code stops using them. See Migrations.
  4. Restart the web server and the worker. docker compose up -d web worker.
  5. Check the health. docker compose ps shows the health of web, from the /up check. You can also request /up yourself.
  6. Read the logs. docker compose logs -f web worker. A failed start, such as the GIL check or a bad config, shows up here.