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 runstarts, and whatINTERFACE,WORKERS,MAX_THREADSandPROCESSEScontrol. - 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. 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:
3.14t
requires-python = ">=3.14,<4"
1.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.
2. The server
proper run starts Granian, a server written in Rust, 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.
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.
2.1 Interfaces: WSGI and RSGI
INTERFACE picks how Granian talks to the app.
| Interface | How it runs a request | WebSockets |
|---|---|---|
"wsgi" (default) |
On one of Granian's own threads. No event loop, no hand-off. | No |
"rsgi" |
An async entry point hands the request to a thread pool that runs the sync pipeline. | Yes, in the same process |
Proper controllers are sync, so WSGI is the fastest way to serve them: the thread that received the request runs it to the end. RSGI costs one thread hop per request, and in exchange serves WebSockets from the same process. Keep the default unless you have a reason; with WSGI, WebSockets are served by a separate process, described below.
2.2 Workers, threads and processes
Three settings decide how much work the server does at once.
WORKERSis the number of Granian workers in each process. On free-threaded Python they are threads, each with its own event loop, sharing the process and its memory.MAX_THREADSis 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. Under WSGI it is split between the workers of the process (rounded up, at least one per worker).0meansmin(32, cpu_count + 4).PROCESSESis the number of copies of the web serverproper runstarts, all on the same port (the operating system spreads connections between them withSO_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. Granian's own default for these threads is in the hundreds, which would flood the database with connections and buy nothing for Python code that uses the CPU, so Proper sets it explicitly.
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.
2.3 The cable process
WSGI has no WebSockets. When the app uses channels, proper run starts a second process that serves them over RSGI on CABLE_PORT. The channels addon sets CABLE_PORT to PORT + 1 (2301), from the CABLE_PORT environment variable if set. With CABLE_PORT = 0, the default, no cable process starts. With INTERFACE = "rsgi" there is no cable process either, since the web server handles WebSockets itself.
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 the web process are forwarded to the cable process as a signed POST to CABLE_PATH. If the cable process is down, the message is lost and a warning is logged. For more than one machine, use RedisCable. The Channels guide covers both.
2.4 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 process - not only Granian's workers.
Ctrl+C, or a SIGTERM to proper run, shuts down every process it started.
2.5 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.
3. Sizing
These are starting points, not rules. Measure with your own pages before changing them.
- Start with
WORKERS=1andPROCESSES=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_THREADSat 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 ownMAX_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 |
INTERFACE |
"wsgi" |
- | "wsgi" or "rsgi" |
WORKERS |
1 |
WORKERS |
Granian workers (threads) 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 WebSocket process |
CABLE_PATH |
"/cable" |
- | URL path the proxy routes to the cable process |
RELOAD |
None (follows DEBUG) |
- | Restart on code changes |
ALLOW_GIL |
False |
- | Serve on a Python with the GIL |
4. 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:
if env == "prod":
PROTOCOL = "https"
HOST = "YOUR-DOMAIN.com"
4.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.
| 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.
4.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:
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.
4.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.
5. 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:
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.14tinto/python, then installs the dependencies in their own layer, so Docker reuses that layer untilpyproject.tomloruv.lockchange. 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./pythonis the same path in both stages, so the virtualenv's links to the interpreter keep working. It addslibpq5for Postgres andlibvipsfor image processing; the full file has commented lines forpoppler-utilsandffmpegif you preview PDFs or videos. - The app runs as
app, an unprivileged user.storage/andlog/are its writable directories; mount volumes there. - The health check requests the app's
/uproute, which every new app has.
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.
6. Compose and the reverse proxy
6.1 Compose
compose.yml runs the app with Postgres and Redis managed elsewhere, reached through the variables in .env:
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:
webpublishes the web server and the cable port, and mounts thestoragevolume for uploads and the cache file.workeris the same image runningpython workers.py, with the health check turned off because it serves no HTTP.migrateis a one-off job behind thetoolsprofile, sodocker compose upnever starts it. It runsproper db prepare, which runsproper db migrateand thenproper 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
6.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:
# Fingerprinted assets: strip the hash and serve the file from disk
location ~* "^\/assets\/(.*)-[a-z0-9]{12,}\.([a-z0-9]+)$" {
rewrite "^\/assets\/(.*)-[a-f0-9]{12,}\.([a-z0-9]+)$" /assets/$1.$2 break;
try_files $uri =404;
}
location /assets/ {
alias /var/www/myapp/myapp/assets;
}
# WebSockets: the cable process
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/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 500.html page is served by nginx, so visitors see it even when the app is down.
7. 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.
8. Deploy checklist
On each deploy, in this order:
- Build the new image.
docker compose build, ordocker build -t myapp:latest .. - Check the pending migrations.
proper db todolists the ones not yet applied to themaindatabase. - Migrate before starting the new version.
docker compose run --rm migrate, orproper 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. - Restart the web server and the worker.
docker compose up -d web worker. - Check the health.
docker compose psshows the health ofweb, from the/upcheck. You can also request/upyourself. - Read the logs.
docker compose logs -f web worker. A failed start, such as the GIL check or a bad config, shows up here.