Tested on: Ubuntu 24.04 LTS, Python 3.12.3, Caddy 2.x, gunicorn 23.0, Flask 3.x, SQLite as the data store. The pattern is portable to any small Python web app — drop in whichever Flask / Starlette / FastAPI codebase you have.
Why this matters
Most Flask deployment tutorials end at flask run --host 0.0.0.0 and a
hand-wave about Nginx. That gap — between “the app starts” and “the app
is a service my colleagues would trust at 2am” — is where the
operationally interesting decisions live: which user runs it, how it
talks to the reverse proxy, where its secrets live, how it survives a
reboot, how its certificate renews itself.
This guide closes that gap for the small-Flask, single-VPS, low-write case. It is the deployment that powers tools.stackharden.com — you can use this guide to recreate exactly that stack, or as a template for any similarly shaped service.
If you operate a larger fleet (multiple replicas, HA, blue-green, K8s), this is not your guide — see the “deliberately does not cover” section.
What we’re building
┌─────────┐ HTTPS (443) ┌──────────┐
client →│ browser │─────────────────────────→│ Caddy │
└─────────┘ Let's Encrypt cert │ (root → caddy user)
└────┬─────┘
│ unix socket
↓
/run/stackharden-tools/stackharden.sock
│
↓
┌───────────────┐
│ Gunicorn │
│ 3 workers │
│ stackharden │
│ user │
└───────┬───────┘
│
↓
┌───────────────┐
│ Flask app │
└───────┬───────┘
│
↓
/var/lib/stackharden-tools/usage.db
(SQLite — owned by stackharden)
Caddy handles TLS termination and ACME (Let’s Encrypt) automatically. Gunicorn runs as a non-root system user, listens on a Unix socket inside a runtime directory, and is managed by systemd with the hardening directives the rest of this site recommends. SQLite is the initial data store — swappable for PostgreSQL by changing one environment variable.
1. Pre-flight — what you need
- A VPS with Ubuntu 24.04 (the same pattern works on Debian and on
RHEL/AlmaLinux with package-name swaps) running Caddy as the host
web server. If you’re running Nginx instead, see
/guides/nginx-tls-2026/— same outcome, different reverse proxy. - SSH access as a user with
sudorights (theubuntucloud-default account is fine). - DNS A record for the application’s hostname pointing at the VPS’s public IP. Critical: if your DNS is behind Cloudflare, the hostname must be “DNS-only” (gray cloud) at first deploy so that Caddy’s TLS-ALPN-01 challenge reaches the origin. See the gotchas section for the alternative path with the proxy on.
- Your application as a normal Python package — a
wsgi.pyentry, arequirements.txt, and agunicorn.conf.py. The template repo at github.com/PolDVIT/stackharden-tools shows the shape we use; adapt the layout to taste.
If your VPS is a fresh image, run through
/guides/ubuntu-baseline/ before continuing.
This guide assumes the host is already baselined — operator account,
SSH hardened, firewall enabled, automatic security updates configured.
2. System user and directories
The Flask process must not run as root, must not run as ubuntu, and
must not be able to write its own source code.
1sudo adduser --system --group --no-create-home \
2 --home /var/www/tools.example.com \
3 --shell /usr/sbin/nologin \
4 stackharden
5
6sudo install -d -o stackharden -g stackharden -m 0750 /var/lib/stackharden-tools
7sudo install -d -o stackharden -g stackharden -m 0750 /var/log/stackharden-tools
8sudo install -d -o root -g root -m 0755 /etc/stackharden-tools
What each directory is for:
| Path | Purpose | Owned |
|---|---|---|
/var/www/tools.example.com/ |
Code (Python sources + venv + templates) | stackharden:stackharden after step 3 |
/var/lib/stackharden-tools/ |
Runtime state — SQLite DB | stackharden:stackharden |
/var/log/stackharden-tools/ |
Application logs | stackharden:stackharden |
/etc/stackharden-tools/ |
Secrets (env file) — root-owned dir, group-readable file inside | root:root for the dir |
The per-domain /var/www/<hostname>/ convention scales when the same
VPS hosts more than one site; each domain gets its own user and tree.
3. Code, virtualenv, dependencies
Push the application code via rsync from your local checkout (or
git clone if the repo is public or a deploy key is set up). The
example below uses rsync to a working tree owned by the ubuntu
account, which we’ll hand to stackharden at the end.
1# From your local machine:
2rsync -avz --delete \
3 --exclude '.git/' --exclude '.venv/' --exclude '__pycache__/' \
4 --exclude '*.pyc' --exclude '*.db' --exclude '.env*' \
5 ./ [email protected]:/var/www/tools.example.com/
6
7# On the VPS:
8cd /var/www/tools.example.com
9sudo apt install -y python3.12-venv # Ubuntu 24.04 splits this out
10python3 -m venv .venv
11.venv/bin/pip install --upgrade pip
12.venv/bin/pip install -r requirements.txt
python3.12-venv is a separate apt package on Ubuntu 24.04 and is
not installed by default. Discovering this with a half-broken venv is
a recurring time sink — install it explicitly.
4. Secrets — /etc/stackharden-tools/env
Generate a fresh production secret key, write it (and the database URL)
to an environment file readable only by root and the service user.
1# Generate a 48-byte random secret.
2SECRET_KEY=$(openssl rand -base64 48 | tr -d '\n')
3
4# Write the env file. Mode 0640, owned root:stackharden — the service
5# user can read it via group membership; the world cannot.
6sudo tee /etc/stackharden-tools/env > /dev/null <<EOF
7SECRET_KEY=${SECRET_KEY}
8DATABASE_URL=sqlite:////var/lib/stackharden-tools/usage.db
9EOF
10sudo chown root:stackharden /etc/stackharden-tools/env
11sudo chmod 0640 /etc/stackharden-tools/env
The Flask app’s production config refuses to start if SECRET_KEY or
DATABASE_URL is missing from the environment. That is deliberate — a
missing key should fail loudly at startup, not silently fall back to a
hardcoded development value.
A note on the SQLite URL: four slashes after sqlite: is the absolute
path. Three would be relative. Get that wrong and the database lands
somewhere inscrutable.
5. Initialise the database
SQLite first. The same SQLAlchemy model maps cleanly to PostgreSQL —
when the data tier needs more, you change DATABASE_URL and run a one-
shot dump/restore, no model code change.
The database must be created as the service user, so the file lands with the right ownership for the running process to write to:
1sudo chown -R stackharden:stackharden /var/www/tools.example.com/
2
3sudo -u stackharden bash -c '
4 cd /var/www/tools.example.com
5 set -a
6 . /etc/stackharden-tools/env
7 set +a
8 export FLASK_CONFIG=production
9 .venv/bin/python -c "
10from app import create_app
11from app.models import db
12with create_app(\"production\").app_context():
13 db.create_all()
14"
15'
Inside the sudo -u stackharden shell, don’t call sudo cat to read
the env file — stackharden has no sudo rights. Source the file
directly: it’s group-readable to stackharden by design.
6. systemd unit
The full unit, with the hardening directives this site recommends:
1# /etc/systemd/system/stackharden-tools.service
2[Unit]
3Description=StackHarden Tools Flask App
4After=network.target
5
6[Service]
7Type=notify
8User=stackharden
9Group=stackharden
10WorkingDirectory=/var/www/tools.example.com
11EnvironmentFile=/etc/stackharden-tools/env
12Environment="FLASK_CONFIG=production"
13RuntimeDirectory=stackharden-tools
14RuntimeDirectoryMode=0750
15ExecStart=/var/www/tools.example.com/.venv/bin/gunicorn -c gunicorn.conf.py wsgi:app
16ExecReload=/bin/kill -HUP $MAINPID
17Restart=on-failure
18RestartSec=5
19
20# Hardening — same posture as guides/ssh-hardening + ubuntu-baseline.
21NoNewPrivileges=true
22PrivateTmp=true
23PrivateDevices=true
24ProtectSystem=strict
25ProtectHome=true
26ProtectKernelTunables=true
27ProtectKernelModules=true
28ProtectKernelLogs=true
29ProtectControlGroups=true
30ProtectHostname=true
31ProtectClock=true
32RestrictNamespaces=true
33RestrictRealtime=true
34RestrictSUIDSGID=true
35LockPersonality=true
36SystemCallArchitectures=native
37CapabilityBoundingSet=
38AmbientCapabilities=
39ReadWritePaths=/var/log/stackharden-tools /var/lib/stackharden-tools
40
41[Install]
42WantedBy=multi-user.target
A few choices worth understanding:
Type=notify— gunicorn 19+ supports systemd’s sd_notify protocol. systemd waits for “arbiter booted” before marking the unit active, sosystemctl startactually means “ready to serve.”RuntimeDirectory=stackharden-tools— systemd creates/run/stackharden-tools/for us, with the mode below, and cleans it on stop. Gunicorn drops the socket here without needing tmpfiles.d.ProtectSystem=strictcombined withReadWritePaths=is the right shape: everything is read-only by default, the two directories we actually need to write to are named explicitly.CapabilityBoundingSet=(empty) andAmbientCapabilities=(empty) — the gunicorn process gets no Linux capabilities at all. None are needed for binding a unix socket as an unprivileged user.
Install and start:
1sudo install -o root -g root -m 0644 \
2 systemd/stackharden-tools.service \
3 /etc/systemd/system/stackharden-tools.service
4
5sudo systemctl daemon-reload
6sudo systemctl enable --now stackharden-tools
7
8# Verify the socket is there and the process is listening on it:
9sudo ls -la /run/stackharden-tools/
10sudo systemctl status stackharden-tools --no-pager -l | head
You should see stackharden.sock owned by stackharden:stackharden
and the service in active (running) state.
7. Caddy reverse proxy
Caddy handles TLS termination and ACME without certbot. Add a stanza to the Caddyfile:
1tools.example.com {
2 reverse_proxy unix//run/stackharden-tools/stackharden.sock
3 encode gzip zstd
4
5 header Strict-Transport-Security "max-age=63072000; includeSubDomains"
6
7 log {
8 output file /var/log/caddy/tools.example.com.access.log
9 format json
10 }
11}
Three details worth knowing:
-
unix//run/...— the double slash is the unix-scheme delimiter followed by the absolute path, not a typo. -
For Caddy to reach the socket, the
caddyuser needs to be able to traverse/run/stackharden-tools/. The directory is mode0750owned bystackharden:stackharden, so addcaddyto thestackhardengroup:1sudo usermod -a -G stackharden caddy 2sudo systemctl restart caddy # group membership only applies on next process start -
The
/var/log/caddy/directory must exist and be writable by thecaddyuser before you reload Caddy. If it doesn’t,systemctl reload caddyfails silently in some Caddy versions and the new site never comes up. Pre-create:1sudo install -d -o caddy -g caddy -m 0750 /var/log/caddy
Validate and reload:
1sudo caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile
2sudo systemctl reload caddy
Caddy will request a Let’s Encrypt cert via TLS-ALPN-01 on the first HTTPS request to the new hostname. The first request can take 5–15 seconds while the cert is fetched; subsequent requests are instant.
8. Verify end-to-end
1# Cert details — issuer should be Let's Encrypt, CN your hostname.
2echo | openssl s_client -connect tools.example.com:443 \
3 -servername tools.example.com 2>/dev/null \
4 | openssl x509 -noout -subject -issuer -dates
5
6# HTTPS responses on the routes you expect.
7for path in / your-tool/; do
8 curl -sI "https://tools.example.com/$path" \
9 -w " /$path -> HTTP %{http_code}\n" -o /dev/null
10done
11
12# HSTS header is present.
13curl -sI https://tools.example.com/ | grep -i strict-transport-security
Submit a real request and confirm the application processes it end to end. If the app writes anything to its own storage (a usage row, an event log, whatever) check that the file’s ownership and contents match expectations:
1sudo -u stackharden ls -la /var/lib/stackharden-tools/
Gotchas — the bumps we hit
These are the four most likely-to-bite things I encountered standing this up. Each one is small in retrospect; each one cost ~15 minutes the first time.
python3.12-venv is a separate apt package on Ubuntu 24.04
python3 -m venv fails with a clear message (“ensurepip is not
available”) but only after creating a half-broken .venv/ tree.
Install python3.12-venv (or whichever Python’s -venv package
matches your major version) before creating the venv. On other
distros, the equivalent package is python3-venv.
Stale /var/log/caddy/<site>.access.log blocks reload
If Caddy’s first attempt to load the new site creates the log file as
root (or fails partway and leaves a file owned by the user the failed
process was running as), subsequent reloads will fail with
permission denied opening the same file. The error appears in
journalctl -u caddy but systemctl reload itself returns success
quickly. If a reload “succeeds” but the new site isn’t responding,
check the recent journal and look for a stale log file with wrong
ownership in /var/log/caddy/.
Cloudflare proxy + Let’s Encrypt HTTP-01/TLS-ALPN-01 collide
If tools.example.com is behind Cloudflare’s orange-cloud proxy,
Let’s Encrypt’s HTTP-01 and TLS-ALPN-01 challenges hit Cloudflare’s
edge instead of your origin Caddy. Caddy never sees the challenge
request, the cert never issues, and Cloudflare returns HTTP 525
(“SSL handshake failed”) because the origin has no cert.
Two fixes, choose one:
- Gray cloud the record (DNS-only). Caddy reaches the origin directly, ACME succeeds. Loses Cloudflare’s CDN / DDoS benefits for this subdomain.
- Switch Caddy to DNS-01 ACME using the
caddy-dns/cloudflaremodule. Requires a Cloudflare API token scoped to DNS edit; lets you keep the orange cloud.
sudo -u stackharden cannot run sudo
A service user with no sudo rights cannot escalate even to read its
own env file. Either group-read the env file (the pattern this guide
uses — mode 0640, owned root:<service-group>) or, if that doesn’t
fit your model, read the secrets from a secrets manager at startup.
Don’t try to nest sudo inside sudo -u.
What this guide deliberately does not cover
- Multi-host / HA. Two replicas behind a load balancer changes the unix-socket pattern (TCP between LB and gunicorn instead) and raises a real session-state question. Out of scope here.
- Blue-green / zero-downtime deploys. The systemd unit above
causes ~1–2 seconds of unavailability on
systemctl reload. For a low-traffic tools site that’s acceptable. For a busier service, see gunicorn’s--reloadand socket-activation patterns. - Container deploys. Same outcome, very different operational shape; this site is deliberately no-Docker.
- Application-level rate limiting. Caddy has built-in rate-limit
primitives; Nginx is covered in
/guides/nginx-ratelimit/. Either way it’s a separate concern from “the app starts and serves HTTPS.” - Log forwarding off-host. Important once you have more than one server; out of scope for the single-VPS case.