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 sudo rights (the ubuntu cloud-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.py entry, a requirements.txt, and a gunicorn.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, so systemctl start actually 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=strict combined with ReadWritePaths= is the right shape: everything is read-only by default, the two directories we actually need to write to are named explicitly.
  • CapabilityBoundingSet= (empty) and AmbientCapabilities= (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 caddy user needs to be able to traverse /run/stackharden-tools/. The directory is mode 0750 owned by stackharden:stackharden, so add caddy to the stackharden group:

    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 the caddy user before you reload Caddy. If it doesn’t, systemctl reload caddy fails 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/cloudflare module. 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 --reload and 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.