Deployment
From a pull request to a container running on an on-premise VM, and the gates in between.
Two VMs. Images are built and scanned by GitHub Actions, and the deploy itself is an Ansible playbook run by hand, because no runner can reach the internal network yet.
The pipeline
The yellow step is the only gate that matters: nothing reaches the registry that did not pass it. The dotted arrow is the manual hop.
Continuous integration
The backend and frontend workflows are path-filtered, so a frontend-only change does not run the backend suite. Both open with a gate job that skips push-triggered CI when the branch already has an open pull request, which prevents redundant checks.
Scanning
Trivy runs on both the dependencies and the built image, twice each: once producing a full report at every severity as an artifact, and once as a gate that fails only on fixable CRITICAL or HIGH findings.
Failing on unfixable vulnerabilities would block merges.
Images
One reusable workflow builds, scans and optionally pushes a package's image.
| Called by | Pushes | Tags |
|---|---|---|
| The CI workflows | No | Built and scanned |
| The release workflow | Yes | X.Y.Z, X.Y, X, and latest |
Releases
release-please watches main and maintains a release pull request per package, parsing
conventional commits into a changelog and a version bump. Merging it creates the tag, the tag
triggers the build workflow, and that workflow pushes the versioned image.
Feature pull requests squash-merge, one conventional commit each. A promotion pull request into
main must use a merge commit, not a squash, or release-please loses the individual commits it
parses.
Deploying
The deploy is manual on purpose: no self-hosted runner can reach the VMs.
Before the first run
Three files hold every real value. Copy each one from its example and fill it in before running any playbook.
| Copy | To | Holds |
|---|---|---|
secrets.yml.example | secrets.yml | Credentials and keys |
inventory/hosts.example.yml | inventory/hosts.yml | The real hostnames |
inventory/group_vars/all/local.example.yml | local.yml | Admin networks and private addresses |
The committed tree carries only the examples, so a real hostname or network range belongs in one of these and never in a role.
cd infra/ansible
ansible-playbook playbooks/deploy.yml \
-e deploy_package=backend \
-e image_tag=1.2.3The playbooks
| Playbook | Does |
|---|---|
prepare.yml | One-time host setup: users, SSH hardening, Docker, the rendered .env |
app.yml | Application stack: compose file, nginx site, certificate |
deploy.yml | Pull the image tag, run migrations, restart |
restore_db.yml | One-time database restore from a dump |
langfuse.yml | The observability VM |
Rolling back
A rollback run swaps the image tag and skips migrations.
The hosts
Every request from a user arrives through nginx, which terminates TLS and routes it to either the frontend or the backend. Nothing else on the VM is reachable from outside.
Langfuse runs on a VM of its own, so the observability stack doesn't compete with the application for resources.
Configuration and secrets
Secrets
Secrets are written onto the VM once, while config setup is running.
That is enough for a proof of concept, but it is not the destination: secrets should move into a vault, so they can be rotated and audited.
Runtime configuration
The frontend has no variables baked in at build time. It is configured at runtime, so the same image runs in every environment.
Last updated on