Skip to content

Deployment & Configuration

  • 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 postgres compose 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.py via subprocess on the same box, not over SSH.
  • No shell injection surface: every subprocess call uses an explicit argument list, never shell=True or string-built commands.

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

Two scripts, run in order, take a fresh box from nothing to a running, web-installable portal.

  1. 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 ubuntu

    Needs the gh CLI 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.

  2. setup-new-machine.sh (repo root) – run on the target host (ssh in 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 \
    --acme-email [email protected] \
    --use-staging-first # first time on a genuinely new domain

    This 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:

OpenVPN Install page’s password confirmation gate

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 marked Secure.

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:

Terminal window
python3 scripts/migrate_sqlite_to_postgres.py \
--sqlite-url sqlite:////opt/openvpn-toolkit/app/data/app.db \
--postgres-url postgresql://vpnadmin:<password>@localhost:5432/vpnadmin

This 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.

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.

Settings page: Platform Settings and Outbound Email cards

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.

Image builds are tag-triggered, not push-triggered:

Terminal window
git tag v1.0.0
git push --tags

That 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.