Deployment

FastAPI Rebuild › Chapter 10 — Final

Going Live

Gunicorn + nginx + systemd + certbot SSL — replacing Apache and deploying osztromok.com to production

Chapter 10 of 10 gunicorn nginx Let's Encrypt systemd
Final Milestone

osztromok.com is served by FastAPI + Gunicorn behind nginx with a valid HTTPS certificate. Apache is retired. The old PHP CMS is gone. Static files (CSS, JS, kanji pages) are served directly by nginx without touching Python. A one-command deploy script handles future updates.

1 What the production stack looks like

In development you ran uvicorn app.main:app --reload directly. In production, three layers sit between the internet and your Python code:

Browser HTTPS :443
nginx reverse proxy
SSL termination
serves static files
Gunicorn ASGI process manager
localhost:8000
4 uvicorn workers
FastAPI app app.main:app
SQLAlchemy
Jinja2
  • nginx — handles SSL, serves /static/ and /resources/ directly from disk (no Python involved), and proxies everything else to Gunicorn. It also buffers slow clients so Python workers aren't blocked waiting for a slow network connection.
  • Gunicorn — a battle-tested Python process manager. It spawns multiple uvicorn worker processes, restarts any that crash, and distributes requests across them. Think of it as the equivalent of Tomcat in a Java Spring Boot deployment.
  • systemd — keeps Gunicorn running as a system service: starts it on boot, restarts it on crash, captures logs via journalctl.
  • certbot — obtains and auto-renews a free TLS certificate from Let's Encrypt. Runs nginx integration directly.
Why not uvicorn directly? Uvicorn is a single process. If it crashes, the site goes down until you restart it manually. Gunicorn manages a pool of uvicorn workers — if one crashes, Gunicorn spawns a replacement immediately while the others keep serving requests.

2 Pre-flight — code changes before deploying

Three changes to make in the codebase before pushing to the server. None of these break development — they are safe to commit now.

1. Turn off SQLAlchemy query echo

app/database.py — change echo=True to False python
engine = create_engine(
    DATABASE_URL,
    echo=False,       # was True in development — don't log every SQL query in prod
    pool_pre_ping=True,
    pool_recycle=3600,
)

2. Add gunicorn to requirements.txt

requirements.txt — add gunicorn text
fastapi
uvicorn[standard]
gunicorn          # ← new: production process manager
jinja2
sqlalchemy
pymysql
python-multipart
python-dotenv
itsdangerous
passlib[bcrypt]
httpx

3. Commit and push to git

terminal — local machine
$ git add requirements.txt app/database.py
$ git commit -m "Production: disable SQL echo, add gunicorn"
$ git push origin main
Confirm .gitignore covers sensitive files before pushing. The server's .env will be created manually — it must never be in git:
.env  ·  __pycache__/  ·  *.pyc  ·  venv/  ·  .venv/

3 Server setup — packages and app user

SSH into the server and run these commands once. The server is assumed to be running Ubuntu 22.04 or Debian 12 (the most common for personal VPS hosting). All commands run as root or with sudo.

server — install system packages
# Update package list and install required tools
root@server:~# apt update && apt upgrade -y
root@server:~# apt install -y python3 python3-pip python3-venv git nginx certbot python3-certbot-nginx

# Verify Python version — needs 3.10+ for SQLAlchemy 2.0 annotated types
root@server:~# python3 --version
Python 3.11.2
server — create a dedicated app user (security best practice)
# Create a system user with no login shell for running the app
# This limits the blast radius if the app is ever compromised
root@server:~# useradd --system --no-create-home --shell /usr/sbin/nologin osztromok

# Create the app directory and give the app user ownership
root@server:~# mkdir -p /var/www/osztromok
root@server:~# chown osztromok:osztromok /var/www/osztromok

4 Deploy the code and set up the virtualenv

server — clone and install
root@server:~# cd /var/www/osztromok

# Clone from git (use your real repo URL)
root@server:/var/www/osztromok# git clone https://github.com/yourname/osztromok.git .

# Create a virtualenv for the app
root@server:/var/www/osztromok# python3 -m venv venv

# Install all dependencies
root@server:/var/www/osztromok# venv/bin/pip install -r requirements.txt

# Move the existing kanji resource files into the app's resources/ folder
# (they were previously at /var/www/html/japan/ or similar on the old site)
root@server:/var/www/osztromok# mkdir -p resources/japanese/kanji
root@server:/var/www/osztromok# cp /var/www/html/resources/japanese/kanji/*.html resources/japanese/kanji/

# Fix ownership — the app user needs to read all files
root@server:/var/www/osztromok# chown -R osztromok:osztromok /var/www/osztromok

Create the production .env on the server

Never copy .env from your laptop — create it fresh on the server with production credentials. Generate a new SECRET_KEY (different from development).

server — create /var/www/osztromok/.env
root@server:/var/www/osztromok# nano .env
/var/www/osztromok/.env — production values env
DB_HOST=localhost
DB_PORT=3306
DB_USER=your_prod_db_user
DB_PASS=your_prod_db_password
DB_NAME=osztromok_new

# Generate a fresh secret key for production:
# python3 -c "import secrets; print(secrets.token_hex(32))"
SECRET_KEY=paste-a-fresh-64-char-hex-string-here

ADMIN_USERNAME=philip
ADMIN_PASSWORD_HASH=$2b$12$your-bcrypt-hash-from-chapter-7
server — lock down .env permissions
# Only the app user and root should read the .env
root@server:/var/www/osztromok# chmod 640 .env
root@server:/var/www/osztromok# chown osztromok:osztromok .env

5 Test Gunicorn manually before wiring up systemd

Always test Gunicorn directly first. If it fails here, the systemd service will also fail — but the error is much easier to see in the terminal.

server — manual Gunicorn test
root@server:/var/www/osztromok# sudo -u osztromok venv/bin/gunicorn \
    --workers 4 \
    --worker-class uvicorn.workers.UvicornWorker \
    --bind 127.0.0.1:8000 \
    --chdir /var/www/osztromok \
    app.main:app

# Expected output (truncated):
# [INFO] Starting gunicorn 21.x.x
# [INFO] Listening at: http://127.0.0.1:8000
# [INFO] Using worker: uvicorn.workers.UvicornWorker
# [INFO] Booting worker with pid: 12345
# [INFO] Booting worker with pid: 12346
# ... (4 workers total)

# In a second terminal on the server — verify it responds
root@server:~# curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:8000/
200

# Ctrl+C to stop the manual test
--workers 4 is a reasonable default. The classic formula is (2 × CPU cores) + 1. For a VPS with 1 CPU: 3 workers. For 2 CPUs: 5 workers. More workers = more RAM used. For a personal site, 2–4 workers is plenty. Each worker is a separate Python process with its own DB connection pool.

6 systemd service — keeping Gunicorn alive

server — create the service file
root@server:~# nano /etc/systemd/system/osztromok.service
/etc/systemd/system/osztromok.service ini
[Unit]
Description=Osztromok.com FastAPI application
After=network.target mysql.service

[Service]
Type=notify
User=osztromok
Group=osztromok
WorkingDirectory=/var/www/osztromok

# Load .env automatically — gunicorn inherits these as environment vars
EnvironmentFile=/var/www/osztromok/.env

ExecStart=/var/www/osztromok/venv/bin/gunicorn \
    --workers 4 \
    --worker-class uvicorn.workers.UvicornWorker \
    --bind unix:/run/osztromok.sock \
    --umask 007 \
    --timeout 60 \
    --access-logfile /var/log/osztromok/access.log \
    --error-logfile  /var/log/osztromok/error.log \
    app.main:app

# Restart automatically if the process exits unexpectedly
Restart=on-failure
RestartSec=5s

[Install]
WantedBy=multi-user.target
Unix socket instead of TCP port. The service binds to a Unix socket (/run/osztromok.sock) rather than 127.0.0.1:8000. nginx will connect to this socket. A Unix socket is faster than TCP for local communication and doesn't expose a port on the loopback interface.
server — create log dir, enable and start the service
# Create log directory
root@server:~# mkdir -p /var/log/osztromok
root@server:~# chown osztromok:osztromok /var/log/osztromok

# Reload systemd, enable the service (starts on boot), start it now
root@server:~# systemctl daemon-reload
root@server:~# systemctl enable osztromok
root@server:~# systemctl start osztromok

# Check it started cleanly
root@server:~# systemctl status osztromok
# ● osztromok.service — Osztromok.com FastAPI application
#      Active: active (running) since ...
#    Main PID: 14321 (gunicorn)

# Confirm the socket file was created
root@server:~# ls -la /run/osztromok.sock
# srw-rw---- 1 osztromok osztromok 0 ... /run/osztromok.sock

7 nginx — reverse proxy and static file serving

nginx handles SSL and serves static files itself, only forwarding dynamic requests to Gunicorn. This is far more efficient than letting FastAPI's StaticFiles middleware serve files in production — nginx serves static content at memory speed without any Python involved.

server — create nginx site config
root@server:~# nano /etc/nginx/sites-available/osztromok.com
/etc/nginx/sites-available/osztromok.com nginx
# HTTP → HTTPS redirect (certbot will fill in the SSL block below)
server {
    listen 80;
    server_name osztromok.com www.osztromok.com;

    # Certbot uses this for the ACME challenge during certificate issuance
    location /.well-known/acme-challenge/ {
        root /var/www/certbot;
    }

    # Everything else redirects to HTTPS
    location / {
        return 301 https://$host$request_uri;
    }
}

# HTTPS — main server block
server {
    listen 443 ssl;
    server_name osztromok.com www.osztromok.com;

    # SSL certificates — certbot fills these in automatically
    ssl_certificate     /etc/letsencrypt/live/osztromok.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/osztromok.com/privkey.pem;
    include             /etc/letsencrypt/options-ssl-nginx.conf;
    ssl_dhparam         /etc/letsencrypt/ssl-dhparams.pem;

    # Security headers
    add_header X-Content-Type-Options "nosniff";
    add_header X-Frame-Options "SAMEORIGIN";
    add_header Referrer-Policy "strict-origin-when-cross-origin";

    # ── Static files served directly by nginx (no Python) ──────────────

    location /static/ {
        alias /var/www/osztromok/app/static/;
        expires 30d;
        add_header Cache-Control "public, immutable";
    }

    location /resources/ {
        alias /var/www/osztromok/resources/;
        expires 7d;
        add_header Cache-Control "public";
        # Allow kanji HTML files to be served with correct content type
        types { text/html html htm; }
    }

    # ── Everything else → Gunicorn via Unix socket ──────────────────────

    location / {
        proxy_pass         http://unix:/run/osztromok.sock;
        proxy_http_version 1.1;
        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 60s;
        proxy_send_timeout 60s;
    }
}
server — enable the site and test nginx config
# Enable the site (creates a symlink in sites-enabled/)
root@server:~# ln -s /etc/nginx/sites-available/osztromok.com /etc/nginx/sites-enabled/
root@server:~# rm -f /etc/nginx/sites-enabled/default   # remove the default placeholder

# Test the config before reloading
root@server:~# nginx -t
# nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
# nginx: configuration file /etc/nginx/nginx.conf test is successful

root@server:~# systemctl reload nginx
nginx needs to read the socket. The socket at /run/osztromok.sock is owned by the osztromok user. nginx runs as the www-data user by default. Add www-data to the osztromok group so nginx can connect:
usermod -aG osztromok www-data
Then restart nginx: systemctl restart nginx

8 certbot — free SSL from Let's Encrypt

Certbot reads the existing nginx config, obtains a certificate for your domain, automatically edits the nginx config to add the SSL directives, and sets up a cron job to renew the certificate before it expires (every 90 days).

DNS must point to the server before running certbot. Let's Encrypt verifies domain ownership by making an HTTP request to your domain. If osztromok.com still points to the old server (or if the old Apache server is still running on port 80), certbot will fail. Check that the domain resolves to this server's IP before continuing.
server — obtain certificate
root@server:~# certbot --nginx -d osztromok.com -d www.osztromok.com

# Certbot will ask for:
#   Email address (for renewal reminders)
#   Agree to TOS: Y
#   Share email with EFF: optional
#
# It then automatically:
#   1. Obtains certificate
#   2. Edits /etc/nginx/sites-available/osztromok.com to add ssl_certificate lines
#   3. Reloads nginx
#
# Final output:
# Congratulations! Your certificate and chain have been saved at:
#   /etc/letsencrypt/live/osztromok.com/fullchain.pem

# Test auto-renewal (dry run — no actual renewal)
root@server:~# certbot renew --dry-run
Auto-renewal is automatic. Certbot installs a systemd timer (certbot.timer) or a cron job that runs twice daily and renews any certificate expiring within 30 days. You never need to manually renew. Check that the timer is active: systemctl status certbot.timer

9 Switching off Apache

Apache and nginx cannot both listen on port 80 and 443 at the same time. The switch needs to be clean and fast. The recommended order:

  1. Verify the new FastAPI app works correctly on the server via curl http://127.0.0.1:8000/
  2. Run python -m scripts.verify_routes against the local Gunicorn to confirm all URLs return 200
  3. Stop Apache and start nginx in quick succession (30-second outage at most)
  4. Test the live site over HTTPS immediately
server — the switchover
# Check Apache is running (to confirm it's the current server)
root@server:~# systemctl status apache2

# Stop Apache and disable it from starting on boot
root@server:~# systemctl stop apache2
root@server:~# systemctl disable apache2

# Start nginx (if not already running after certbot)
root@server:~# systemctl start nginx
root@server:~# systemctl enable nginx

# Immediately verify the live site responds
root@server:~# curl -s -o /dev/null -w "%{http_code}\n" https://osztromok.com/
200

# Check that HTTP redirects to HTTPS
root@server:~# curl -s -o /dev/null -w "%{http_code}\n" http://osztromok.com/
301
Rollback plan if the new site has a critical problem: systemctl stop nginx && systemctl start apache2 The old PHP files and database are untouched — Apache can serve the old site while you debug. Only delete the old PHP files once you are confident the new site is working correctly in production.

10 Enable https_only in SessionMiddleware

Now that SSL is live, flip the flag in app/main.py that was deliberately left False in Chapter 7:

app/main.py — final production SessionMiddleware setting python
app.add_middleware(
    SessionMiddleware,
    secret_key    = os.getenv("SECRET_KEY"),
    session_cookie= "osztromok_session",
    max_age       = 86400 * 7,
    same_site     = "lax",
    https_only    = True,    # ← flip to True now that SSL is live
)

Commit this change and deploy it via the update workflow below. With https_only=True, the session cookie gets the Secure attribute — browsers will never send it over plain HTTP.

11 The update workflow — deploying future changes

Every future code change follows the same sequence. Create a shell script on the server so you can deploy with one command.

/var/www/osztromok/deploy.sh bash
#!/usr/bin/env bash
set -e   # exit immediately if any command fails

APP_DIR=/var/www/osztromok

echo "=== Deploying osztromok.com ==="

# Pull latest code
cd "$APP_DIR"
git pull origin main

# Update dependencies (safe to run even if nothing changed)
venv/bin/pip install -r requirements.txt --quiet

# Apply any pending Alembic schema migrations
venv/bin/alembic upgrade head

# Restart the app (Gunicorn workers reload gracefully)
systemctl restart osztromok

# Brief wait, then verify it's running
sleep 2
systemctl is-active osztromok && echo "✓ Service is running" || echo "✗ Service failed!"

echo "=== Deploy complete ==="
server — make deploy.sh executable
root@server:~# chmod +x /var/www/osztromok/deploy.sh

# Future deploys are one command (run as root or with sudo):
root@server:~# /var/www/osztromok/deploy.sh
Gunicorn graceful restart. systemctl restart osztromok sends Gunicorn a SIGTERM. Gunicorn finishes any in-flight requests on existing workers before shutting them down, then starts fresh workers with the new code. Users mid-request experience at most a slight delay, not an error. In Java/Spring Boot terms: this is equivalent to a rolling restart in Kubernetes.

12 Viewing logs and debugging production issues

server — useful log commands
# Gunicorn service logs (Python exceptions land here)
root@server:~# journalctl -u osztromok -f          # live tail
root@server:~# journalctl -u osztromok --since "1 hour ago"

# Gunicorn access log (all HTTP requests to the app)
root@server:~# tail -f /var/log/osztromok/access.log

# Gunicorn error log (Python tracebacks)
root@server:~# tail -f /var/log/osztromok/error.log

# nginx access log (all requests including static files)
root@server:~# tail -f /var/log/nginx/access.log

# nginx error log (connection refused, bad gateway etc.)
root@server:~# tail -f /var/log/nginx/error.log

# Check service status at a glance
root@server:~# systemctl status osztromok nginx

Common production errors

  • 502 Bad Gateway — nginx can't reach Gunicorn. Check the socket: ls /run/osztromok.sock. If missing, Gunicorn crashed: journalctl -u osztromok -n 50
  • 500 Internal Server Error — Python exception in the app. Check /var/log/osztromok/error.log for the traceback
  • 403 Forbidden on /resources/ — file permission issue. Run chmod -R 755 /var/www/osztromok/resources/
  • SESSION_KEY error at startup.env not loaded. Confirm the EnvironmentFile= path in the service file is correct

13 Post-launch checklist

  • https://osztromok.com loads correctly with a valid padlock
  • http://osztromok.com redirects to https (301)
  • www.osztromok.com also works (nginx handles both)
  • Login at /admin/login works and session persists across requests
  • A kanji URL like /japan/kanji/水 redirects to the resource file
  • Static CSS is served by nginx — verify in DevTools Network tab (no Python in the path)
  • Run python -m scripts.verify_routes pointing at https://osztromok.com — all 200s
  • systemctl status osztromok shows active (running)
  • systemctl status nginx shows active (running)
  • systemctl is-enabled osztromok nginx shows both enabled (survives reboot)
  • Apache is stopped and disabled — systemctl status apache2 shows inactive
  • Test a reboot: reboot, then check the site comes back automatically

✓ Chapter 10 Complete — Deployment done

  • Gunicorn — 4 uvicorn workers, Unix socket, 60s timeout
  • systemd service — starts on boot, restarts on crash, logs to journalctl and log files
  • nginx — serves /static/ and /resources/ directly (no Python); proxies everything else to the Gunicorn socket
  • certbot — free Let's Encrypt certificate, auto-renews via systemd timer
  • https_only=True — session cookie now carries the Secure attribute
  • deploy.sh — git pull → pip install → alembic upgrade → systemctl restart in one command
  • Apache retired — stopped, disabled, site files preserved as rollback option
  • Post-launch checklist complete — HTTPS, redirects, admin login, kanji URLs, static files, all verified

🎉 Course complete

osztromok.com is rebuilt — from a PHP CMS held together with .htaccess rules to a clean FastAPI application with typed models, a proper admin interface, flexible routing, and automatic HTTPS. Ten chapters, one real project.

Ch 1
Project Setup
Ch 2
Database Models
Ch 3
Jinja2 Templates
Ch 4
Core CMS Routing
Ch 5
Navigation & Structure
Ch 6
Flexible Routing
Ch 7
Authentication
Ch 8
Admin CRUD + TinyMCE
Ch 9
Content Migration
Ch 10
Deployment ✓

Quick Reference — Chapter 10

gunicorn -k uvicorn.workers.UvicornWorker app.main:app Run FastAPI with Gunicorn's uvicorn worker class
--bind unix:/run/app.sock --umask 007 Unix socket — faster than TCP for local nginx→gunicorn
systemctl enable & start osztromok Start service now and on every future boot
journalctl -u osztromok -f Live tail of the gunicorn service logs
nginx -t && systemctl reload nginx Test nginx config then reload without dropping connections
certbot --nginx -d domain.com Obtain free TLS cert and auto-configure nginx
certbot renew --dry-run Test that auto-renewal will work without actually renewing
systemctl stop apache2 && systemctl start nginx The switchover — Apache off, nginx on