VPS DeploymentIntermediate 15 min Lesson 19 of 30

Deploying Python and FastAPI

Run a Python web application in production: worker model, systemd service, migrations, static files and graceful restarts.

VPS Deployment · Lesson 19 of 30
0/30 done(0%)

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 #

BASH
#!/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">&quot;</mi><mi mathvariant="normal">&quot;</mi></mrow><annotation encoding="application/x-tex">APP/shared/.env&quot; &quot;</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">&quot;&quot;</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">&quot;</mi><mi mathvariant="normal">&quot;</mi></mrow><annotation encoding="application/x-tex">APP/shared/uploads&quot; &quot;</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">&quot;&quot;</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">&quot;</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">&quot;</mi></mrow><annotation encoding="application/x-tex">RELEASE/.venv/bin/pip&quot; install -r &quot;</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">&quot;</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">&quot;</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">&quot;</mi><mi mathvariant="normal">&quot;</mi></mrow><annotation encoding="application/x-tex">RELEASE&quot; &quot;</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">&quot;&quot;</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">&quot;</mi><mi mathvariant="normal">&quot;</mi></mrow><annotation encoding="application/x-tex">APP/current.new&quot; &quot;</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">&quot;&quot;</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
INI
# /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
TEXT
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.

Quick quiz

  1. 1. Why not use the development server in production?

  2. 2. What does `--max-requests` with jitter achieve?

  3. 3. Why must migrations be backwards compatible?

  4. 4. What does a SIGHUP reload do in Gunicorn?

  5. 5. Why does the worker count affect database connections?

Summary

  • Use Gunicorn (with Uvicorn workers for async) under systemd behind Nginx.
  • A fresh virtual environment per release keeps rollbacks accurate.
  • Run backwards-compatible migrations before switching.
  • Use graceful timeouts and SIGHUP reloads for zero-downtime deploys.
  • Worker count multiplies memory and database connections.