What is it? #
The development server is not for production. It is single-process, reloads on file changes, and has no concurrency model worth relying on.
Production means a WSGI or ASGI server — Gunicorn, often with Uvicorn workers for async frameworks — running under systemd behind Nginx.
The structure is the same as the Node deployment: release directories, a shared env file, an atomic switch and a health check.
The Python-specific parts are the worker model, rebuilding the virtual environment, and running migrations in the right order.
Think of it like this #
A workshop with several benches rather than one. Each bench handles one job at a time; more benches means more jobs in parallel, up to the point where the room is full.
Simple example #
A FastAPI application deployed with Gunicorn and Uvicorn workers, a systemd service, Alembic migrations run before the switch, and static files served by Nginx.
Code #
#!/usr/bin/env bash
# /srv/app/deploy.sh
set -euo pipefail
APP=/srv/app
STAMP=$(date +%Y%m%d-%H%M%S)
RELEASE="<span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>A</mi><mi>P</mi><mi>P</mi><mi mathvariant="normal">/</mi><mi>r</mi><mi>e</mi><mi>l</mi><mi>e</mi><mi>a</mi><mi>s</mi><mi>e</mi><mi>s</mi><mi mathvariant="normal">/</mi></mrow><annotation encoding="application/x-tex">APP/releases/</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="katex-base"><span class="katex-strut" style="height:1em;vertical-align:-0.25em;"></span><span class="mord mathnormal">A</span><span class="mord mathnormal" style="margin-right:0.1389em;">P</span><span class="mord mathnormal" style="margin-right:0.1389em;">P</span><span class="mord">/</span><span class="mord mathnormal" style="margin-right:0.0278em;">r</span><span class="mord mathnormal">e</span><span class="mord mathnormal" style="margin-right:0.0197em;">l</span><span class="mord mathnormal">e</span><span class="mord mathnormal">a</span><span class="mord mathnormal">ses</span><span class="mord">/</span></span></span></span>STAMP"
git clone --depth 1 --branch main [email protected]:org/api.git "$RELEASE"
ln -s "<span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>A</mi><mi>P</mi><mi>P</mi><mi mathvariant="normal">/</mi><mi>s</mi><mi>h</mi><mi>a</mi><mi>r</mi><mi>e</mi><mi>d</mi><mi mathvariant="normal">/</mi><mi mathvariant="normal">.</mi><mi>e</mi><mi>n</mi><mi>v</mi><mi mathvariant="normal">"</mi><mi mathvariant="normal">"</mi></mrow><annotation encoding="application/x-tex">APP/shared/.env" "</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="katex-base"><span class="katex-strut" style="height:1em;vertical-align:-0.25em;"></span><span class="mord mathnormal">A</span><span class="mord mathnormal" style="margin-right:0.1389em;">P</span><span class="mord mathnormal" style="margin-right:0.1389em;">P</span><span class="mord">/</span><span class="mord mathnormal">s</span><span class="mord mathnormal">ha</span><span class="mord mathnormal" style="margin-right:0.0278em;">r</span><span class="mord mathnormal">e</span><span class="mord mathnormal">d</span><span class="mord">/.</span><span class="mord mathnormal">e</span><span class="mord mathnormal">n</span><span class="mord mathnormal" style="margin-right:0.0359em;">v</span><span class="mord">""</span></span></span></span>RELEASE/.env"
ln -s "<span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>A</mi><mi>P</mi><mi>P</mi><mi mathvariant="normal">/</mi><mi>s</mi><mi>h</mi><mi>a</mi><mi>r</mi><mi>e</mi><mi>d</mi><mi mathvariant="normal">/</mi><mi>u</mi><mi>p</mi><mi>l</mi><mi>o</mi><mi>a</mi><mi>d</mi><mi>s</mi><mi mathvariant="normal">"</mi><mi mathvariant="normal">"</mi></mrow><annotation encoding="application/x-tex">APP/shared/uploads" "</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="katex-base"><span class="katex-strut" style="height:1em;vertical-align:-0.25em;"></span><span class="mord mathnormal">A</span><span class="mord mathnormal" style="margin-right:0.1389em;">P</span><span class="mord mathnormal" style="margin-right:0.1389em;">P</span><span class="mord">/</span><span class="mord mathnormal">s</span><span class="mord mathnormal">ha</span><span class="mord mathnormal" style="margin-right:0.0278em;">r</span><span class="mord mathnormal">e</span><span class="mord mathnormal">d</span><span class="mord">/</span><span class="mord mathnormal">u</span><span class="mord mathnormal" style="margin-right:0.0197em;">pl</span><span class="mord mathnormal">o</span><span class="mord mathnormal">a</span><span class="mord mathnormal">d</span><span class="mord mathnormal">s</span><span class="mord">""</span></span></span></span>RELEASE/uploads"
# A fresh virtual environment per release: no drift, easy rollback
python3.12 -m venv "$RELEASE/.venv"
"$RELEASE/.venv/bin/pip" install --upgrade pip
"<span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>R</mi><mi>E</mi><mi>L</mi><mi>E</mi><mi>A</mi><mi>S</mi><mi>E</mi><mi mathvariant="normal">/</mi><mi mathvariant="normal">.</mi><mi>v</mi><mi>e</mi><mi>n</mi><mi>v</mi><mi mathvariant="normal">/</mi><mi>b</mi><mi>i</mi><mi>n</mi><mi mathvariant="normal">/</mi><mi>p</mi><mi>i</mi><mi>p</mi><mi mathvariant="normal">"</mi><mi>i</mi><mi>n</mi><mi>s</mi><mi>t</mi><mi>a</mi><mi>l</mi><mi>l</mi><mo>−</mo><mi>r</mi><mi mathvariant="normal">"</mi></mrow><annotation encoding="application/x-tex">RELEASE/.venv/bin/pip" install -r "</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="katex-base"><span class="katex-strut" style="height:1em;vertical-align:-0.25em;"></span><span class="mord mathnormal" style="margin-right:0.0077em;">R</span><span class="mord mathnormal" style="margin-right:0.0576em;">E</span><span class="mord mathnormal">L</span><span class="mord mathnormal" style="margin-right:0.0576em;">E</span><span class="mord mathnormal">A</span><span class="mord mathnormal" style="margin-right:0.0576em;">S</span><span class="mord mathnormal" style="margin-right:0.0576em;">E</span><span class="mord">/.</span><span class="mord mathnormal" style="margin-right:0.0359em;">v</span><span class="mord mathnormal">e</span><span class="mord mathnormal">n</span><span class="mord mathnormal" style="margin-right:0.0359em;">v</span><span class="mord">/</span><span class="mord mathnormal">bin</span><span class="mord">/</span><span class="mord mathnormal">p</span><span class="mord mathnormal">i</span><span class="mord mathnormal">p</span><span class="mord">"</span><span class="mord mathnormal">in</span><span class="mord mathnormal">s</span><span class="mord mathnormal">t</span><span class="mord mathnormal">a</span><span class="mord mathnormal" style="margin-right:0.0197em;">l</span><span class="mord mathnormal" style="margin-right:0.0197em;">l</span><span class="mspace" style="margin-right:0.2222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222em;"></span></span><span class="katex-base"><span class="katex-strut" style="height:0.6944em;"></span><span class="mord mathnormal" style="margin-right:0.0278em;">r</span><span class="mord">"</span></span></span></span>RELEASE/requirements.txt"
# Migrations run BEFORE the switch, and must be backwards compatible
cd "$RELEASE"
set -a; source "$APP/shared/.env"; set +a
"$RELEASE/.venv/bin/alembic" upgrade head
# Collect static files (Django)
# "$RELEASE/.venv/bin/python" manage.py collectstatic --noinput
ln -sfn "<span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>R</mi><mi>E</mi><mi>L</mi><mi>E</mi><mi>A</mi><mi>S</mi><mi>E</mi><mi mathvariant="normal">"</mi><mi mathvariant="normal">"</mi></mrow><annotation encoding="application/x-tex">RELEASE" "</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="katex-base"><span class="katex-strut" style="height:0.6944em;"></span><span class="mord mathnormal" style="margin-right:0.0077em;">R</span><span class="mord mathnormal" style="margin-right:0.0576em;">E</span><span class="mord mathnormal">L</span><span class="mord mathnormal" style="margin-right:0.0576em;">E</span><span class="mord mathnormal">A</span><span class="mord mathnormal" style="margin-right:0.0576em;">S</span><span class="mord mathnormal" style="margin-right:0.0576em;">E</span><span class="mord">""</span></span></span></span>APP/current.new"
mv -T "<span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>A</mi><mi>P</mi><mi>P</mi><mi mathvariant="normal">/</mi><mi>c</mi><mi>u</mi><mi>r</mi><mi>r</mi><mi>e</mi><mi>n</mi><mi>t</mi><mi mathvariant="normal">.</mi><mi>n</mi><mi>e</mi><mi>w</mi><mi mathvariant="normal">"</mi><mi mathvariant="normal">"</mi></mrow><annotation encoding="application/x-tex">APP/current.new" "</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="katex-base"><span class="katex-strut" style="height:1em;vertical-align:-0.25em;"></span><span class="mord mathnormal">A</span><span class="mord mathnormal" style="margin-right:0.1389em;">P</span><span class="mord mathnormal" style="margin-right:0.1389em;">P</span><span class="mord">/</span><span class="mord mathnormal">c</span><span class="mord mathnormal">u</span><span class="mord mathnormal" style="margin-right:0.0278em;">r</span><span class="mord mathnormal" style="margin-right:0.0278em;">r</span><span class="mord mathnormal">e</span><span class="mord mathnormal">n</span><span class="mord mathnormal">t</span><span class="mord">.</span><span class="mord mathnormal">n</span><span class="mord mathnormal">e</span><span class="mord mathnormal" style="margin-right:0.0269em;">w</span><span class="mord">""</span></span></span></span>APP/current"
sudo systemctl reload app || sudo systemctl restart app
for i in {1..15}; do
curl -fsS http://127.0.0.1:8000/health > /dev/null && { echo healthy; break; }
[ "$i" = 15 ] && { echo "FAILED — rolling back"; exit 1; }
sleep 2
done
ls -1dt "$APP"/releases/* | tail -n +6 | xargs -r rm -rf
# /etc/systemd/system/app.service
[Unit]
Description=FastAPI application
After=network-online.target postgresql.service
[Service]
Type=simple
User=appuser
WorkingDirectory=/srv/app/current
EnvironmentFile=/srv/app/shared/.env
Environment=PYTHONUNBUFFERED=1
ExecStart=/srv/app/current/.venv/bin/gunicorn app.main:app \
--worker-class uvicorn.workers.UvicornWorker \
--workers 4 \
--bind 127.0.0.1:8000 \
--timeout 60 \
--graceful-timeout 30 \
--max-requests 1000 --max-requests-jitter 100 \
--access-logfile - --error-logfile -
ExecReload=/bin/kill -s HUP $MAINPID
Restart=always
RestartSec=5
KillSignal=SIGTERM
TimeoutStopSec=45
MemoryMax=1G
[Install]
WantedBy=multi-user.target
Choosing the worker count
CPU-bound sync code workers = (2 x cores) + 1
async (ASGI) fewer workers; each handles many concurrent requests
memory-bound total memory / memory per worker
Start with 4 on a 2-core machine, measure, then adjust. Each worker is a
separate process with its own memory and its own database connections —
4 workers with a pool of 10 means 40 connections.
How it works #
Gunicorn runs several worker processes and distributes requests between them. With UvicornWorker, each worker runs an async event loop, which is what FastAPI needs.
--max-requests with jitter recycles workers after a number of requests. It contains slow memory leaks, and the jitter prevents all workers restarting simultaneously.
--graceful-timeout gives a worker time to finish its current request when being replaced. Combined with TimeoutStopSec=45, in-flight requests complete rather than being cut off.
ExecReload sending SIGHUP makes Gunicorn start new workers and retire the old ones gradually. That is a zero-downtime reload — new code is picked up without dropping connections, which is why the deploy script tries reload before restart.
A fresh virtual environment per release means the environment always matches that release's requirements, and rolling back restores the matching dependencies automatically.
Migrations run before the switch and must be backwards compatible, because the old code is still serving while they apply. Adding a nullable column is safe; dropping a column the old code still reads is not.
Logging to - sends access and error logs to standard output, where systemd captures them into the journal.
Each worker holds its own database connections, which is why the worker count and the pool size multiply — a common cause of exhausting database connections.
Real-world use #
Gunicorn with Uvicorn workers is the standard FastAPI production setup, and Gunicorn alone is standard for Django and Flask.
The reload behaviour is a genuine advantage over restarting: deployments become invisible to users on a single server.
Migration ordering is where most deployment incidents originate. The safe sequence for a destructive change is: deploy code that no longer uses the column, verify, then deploy the migration that drops it.
Background workers — Celery, RQ, or a custom loop — run as separate systemd services with their own unit files and their own scaling.
Memory limits per service are worth setting. A leaking worker then gets restarted by the recycling policy or the memory limit instead of triggering the OOM killer on the database.
Common mistakes #
- Running the development server in production.
- Too many workers, exhausting memory or database connections.
- Running destructive migrations while the old code is still serving.
- No graceful timeout, so deployments drop in-flight requests.
- Installing dependencies into a shared virtual environment, losing rollback fidelity.
Practice #
Deploy a small FastAPI application with Gunicorn and Uvicorn workers under systemd, behind Nginx. Include a fresh virtual environment per release, a migration step and a health check. Then trigger a reload and confirm no requests fail during it.