Deployment & Configuration
Architecture
Section titled “Architecture”- Backend: FastAPI, server-rendered HTML (Jinja2) + a bit of vanilla JS calling a JSON API – no separate frontend build step.
- Database: SQLAlchemy, works with either SQLite (zero-setup default,
still the right choice for local/dev) or PostgreSQL (production, via the
postgrescompose service). Only stores this app’s own accounts and audit log – VPN client data always comes live from the CLI scripts. - Runs co-located with the OpenVPN server: calls
openvpn-install.sh/vpn-status.pyviasubprocesson the same box, not over SSH. - No shell injection surface: every subprocess call uses an explicit
argument list, never
shell=Trueor string-built commands.
Bind mounts
Section titled “Bind mounts”docker-compose.yml bind-mounts what the container needs from the host
(paths relative to the repo root, where the compose file lives):
| Mount | Why |
|---|---|
/etc/openvpn:/etc/openvpn (rw) |
--add-user/--revoke-user write new certs and update openvpn_db.txt here |
/var/log/openvpn:/var/log/openvpn (ro) |
vpn-status.py reads connection/rejection history from here |
./openvpn-install.sh, ./vpn-status.py (ro) |
the scripts this app wraps – bind-mounted, not baked into the image, so a git pull on the host takes effect without rebuilding |
./app/data |
SQLite file persistence, only relevant if DATABASE_URL stays on the SQLite default |
First-time setup on a new machine
Section titled “First-time setup on a new machine”Two scripts, run in order, take a fresh box from nothing to a running, web-installable portal.
-
add-machine.sh(repo root) – run from your own machine, not the target host:Terminal window ./add-machine.sh --host 203.0.113.10 --user ubuntuNeeds the
ghCLI authenticated locally and that you can already SSH to the target host. It generates an ed25519 keypair on the target host (the private half never leaves it), registers the public half as a read-only deploy key on the repo, wires up an SSH config alias, and clones (or fetches) into/opt/openvpn-toolkit. Idempotent – safe to re-run against a box that already has some of this done. -
setup-new-machine.sh(repo root) – run on the target host (sshin first). Prerequisites this one doesn’t cover: Docker + the Compose plugin already installed, and a DNS A record for your domain already pointing at the host’s public IP.Terminal window sudo /opt/openvpn-toolkit/setup-new-machine.sh \--domain portal.example.com \--use-staging-first # first time on a genuinely new domainThis automates the rest a fresh host needs for the OpenVPN Install page’s web-triggered install/uninstall to work: generating the scoped SSH “host executor” key + forced-command wrapper + sudoers grant, writing
.env, enabling the deploy-key volume mount, and bringing the stack up (Let’s Encrypt staging cert first if requested, then production).
Re-running setup-new-machine.sh is safe – every phase checks its own
current state first, so keys/sudoers/authorized_keys entries aren’t
duplicated, and an existing .env is left alone unless you pass
--force-env.
Once running, the OpenVPN Install page lets an admin bring OpenVPN up or down on the host over that scoped connection – it re-checks your password every 15 minutes, independent of your normal login session, and only the bootstrap admin account can reach it at all:

TLS / reverse proxy (Traefik)
Section titled “TLS / reverse proxy (Traefik)”docker-compose.yml includes a traefik service fronting app: it
terminates TLS, issues/renews a Let’s Encrypt certificate automatically via
the HTTP-01 challenge, and redirects plain HTTP to HTTPS. Configure it via
.env:
APP_DOMAIN– the public hostname to request a cert for; must already resolve to this host’s public IP, and ports 80/443 must be reachable from the internet.ACME_EMAIL– a real, monitored mailbox; Let’s Encrypt sends expiry/problem notices here.ACME_CASERVER– leave unset for Let’s Encrypt production. Point at the staging directory first when standing this up or changing domains, to avoid burning production rate-limit attempts on a config that isn’t verified yet.SESSION_HTTPS_ONLY=true– once Traefik is actually terminating TLS, set this so the session cookie is markedSecure.
Database: SQLite (dev) vs PostgreSQL (production)
Section titled “Database: SQLite (dev) vs PostgreSQL (production)”SQLite remains the zero-setup choice for local/dev. Production runs
PostgreSQL: set DATABASE_URL=postgresql://vpnadmin:<password>@postgres:5432/vpnadmin
in .env and a POSTGRES_PASSWORD. Data persists in the named pgdata
volume across container recreation.
Moving an existing SQLite deployment into Postgres:
python3 scripts/migrate_sqlite_to_postgres.py \ --sqlite-url sqlite:////opt/openvpn-toolkit/app/data/app.db \ --postgres-url postgresql://vpnadmin:<password>@localhost:5432/vpnadminThis reuses the app’s own SQLAlchemy models against both databases (never hand-translated SQL) to copy every row table-by-table, preserving ids/foreign keys/timestamps, and prints a per-table row-count comparison at the end. Keep the original SQLite file around as a safety net even after cutting over.
Runtime settings
Section titled “Runtime settings”Branding, deployment, SMTP, security, and audit-log-retention are editable
at runtime from the Settings page (/settings, admin-only), stored in
the database, and take effect immediately app-wide – no .env edit or
restart needed. Environment variables remain the seed/fallback default for
anything never touched on this page.

The app’s branding (name, logo, favicon) is fixed and not admin-configurable – only the Portal URL (used in the welcome email) and outbound SMTP are.
Releases
Section titled “Releases”Image builds are tag-triggered, not push-triggered:
git tag v1.0.0git push --tagsThat runs the full pipeline (test → build/push → Trivy scan) and
publishes ghcr.io/cloudlative/cyferio-app:1.0.0 and :latest – note: no
leading v on the published image tag, even though the git tag itself is
vX.Y.Z. A plain git push to master only runs the test job – no image
is built or published.
To deploy an exact version instead of always tracking latest, set
IMAGE_TAG=X.Y.Z in .env. Rolling back a bad deploy: set IMAGE_TAG to
a known-good previous version and re-run
docker compose pull && docker compose up -d.
The container runs as root (needed to touch root-owned /etc/openvpn and
run easyrsa) with USE_SUDO=false – no sudo binary needed since the
process already has the privilege it would otherwise escalate to.