C3TMC

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 byPushesTags
The CI workflowsNoBuilt and scanned
The release workflowYesX.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.

CopyToHolds
secrets.yml.examplesecrets.ymlCredentials and keys
inventory/hosts.example.ymlinventory/hosts.ymlThe real hostnames
inventory/group_vars/all/local.example.ymllocal.ymlAdmin 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.3

The playbooks

PlaybookDoes
prepare.ymlOne-time host setup: users, SSH hardening, Docker, the rendered .env
app.ymlApplication stack: compose file, nginx site, certificate
deploy.ymlPull the image tag, run migrations, restart
restore_db.ymlOne-time database restore from a dump
langfuse.ymlThe 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

On this page