10
Going Live
Gunicorn + nginx + systemd + certbot SSL — replacing Apache and deploying osztromok.com to production
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:
SSL termination
serves static files
localhost:8000
4 uvicorn workers
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.
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
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
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
$ git add requirements.txt app/database.py $ git commit -m "Production: disable SQL echo, add gunicorn" $ git push origin main
.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.
# 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
# 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
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).
root@server:/var/www/osztromok# nano .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
# 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.
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
(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
root@server:~# nano /etc/systemd/system/osztromok.service
[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
/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.
# 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.
root@server:~# nano /etc/nginx/sites-available/osztromok.com
# 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; } }
# 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
/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).
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
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:
- Verify the new FastAPI app works correctly on the server via
curl http://127.0.0.1:8000/ - Run
python -m scripts.verify_routesagainst the local Gunicorn to confirm all URLs return 200 - Stop Apache and start nginx in quick succession (30-second outage at most)
- Test the live site over HTTPS immediately
# 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
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.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.
#!/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 ==="
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
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
# 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.logfor the traceback - 403 Forbidden on /resources/ — file permission issue.
Run
chmod -R 755 /var/www/osztromok/resources/ - SESSION_KEY error at startup —
.envnot loaded. Confirm theEnvironmentFile=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_routespointing athttps://osztromok.com— all 200s systemctl status osztromokshows active (running)systemctl status nginxshows active (running)systemctl is-enabled osztromok nginxshows both enabled (survives reboot)- Apache is stopped and disabled —
systemctl status apache2shows 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.