TPTito Pinardo Gutiérrez
← Articles
Español

My own CI/CD with Forgejo: a runner with no secrets, deploys by PR and this site publishing itself

My homelab's infrastructure lived in a repository with no interface and nobody reviewed anything before it went in. In one day I moved it to Forgejo: CI on every push on a runner without a single secret, infrastructure changes by PR with the plan in view, one-click deploys and, at the end, this site deploying itself.

  • Forgejo · Actions
  • Git
  • Terraform
  • Ansible
  • Rust

Why

The infrastructure lived in a repository with no interface, copied to a backup one by a hook. It worked, but nobody looked at anything before it went in: a badly formatted Terraform file or a plain-text secret was copied as is to both places.

After auditing everything I had published for leaks on the 5th, what mattered most was gitleaks running on every push by itself. That needed a Git server with CI. I picked Forgejo: one binary and SQLite, which fit in a container with a gigabyte of memory.

Two runners, split by what they can touch

The CI runner lives in its own container and has nothing to steal: no Proxmox API key, no Terraform state, no vault password. It checks Terraform's formatting and validity with no state or credentials, runs ansible-lint, checks that every vault is encrypted and runs gitleaks over the whole history. It never applies anything.

Some tools won't start without certain files, so the runner has a deliberately fake vault password and SSH key. They let things start and open nothing.

The deploy runner lives on the control node, which could already touch everything, and only serves its own repository. It gives no more access than there already was.

The four stumbles of day one

The first CI run went red, as expected. terraform validate uses no state, but it does evaluate file(), and the code reads a public key that didn't exist on the runner. The fake file fixed it.

Jobs sat «waiting» with no error. The runner had registered with no label, so it matched no runs-on. Adding it changed nothing: it only asks for work again when the server's queue changes. Restarting it once was enough.

Forgejo wouldn't start without an OAuth2 secret, even with OAuth2 switched off. Now it's generated the first time inside the container, like the other internal secrets, and never leaves.

On the runner, ansible-lint raised a warning my terminal didn't show: the control node was missing a collection and that role was skipped silently. Since then CI is the reference: it starts from a clean install.

And when I added my machines' SSH keys, two commits went red for lines over 160 characters: the keys themselves. Both ❌ alerts reached Element and the fix went green. Eight alerts went out that day and all eight arrived.

Infrastructure by PR, with the plan in view

The infrastructure's main branch takes no pushes: everything comes in by PR with green CI. On each PR, the deploy runner works out the Terraform plan and dry-runs the playbooks affected by the changed files. The summary is posted as a comment: before merging I already know what gets created, what changes and what would be recreated.

Merging applies nothing. The main branch's plan comes out, I read it on my phone and approve it with one click. That saved plan is applied: if the state changed in between, Terraform refuses instead of doing something else.

What never runs on its own is on a list, with its reason: the control node itself, Forgejo, the host and maintenance. And I deliberately don't use --diff: templates carry secrets and would end up in the log, the PR and Element.

🧭 Plan del PR #1
Terraform (homelab): 1 update
  ~ proxmox_virtual_environment_container.forgejo_runner
Ansible monitoring.yml (forgejo-runner): 0 tarea(s) cambiarian
A mano (no se lanza solo): forgejo.yml — reinicia Forgejo y el runner de despliegue

This site, from a push to the internet

This site came next. Its origin moved to Forgejo and its CI to the same runner, which now also has Rust, a socket-only PostgreSQL for the tests and Node to check the minified JavaScript. Building takes about 1.3 GB of memory with two jobs at once: it fits in the runner's 2 GB, and the build output is reused between runs.

There's no click here: with green CI on main, the deploy runner runs a leak check over everything that isn't public yet. It searches with gitleaks, requires my public identity as the only author and rejects addresses or internal names. Then it builds, deploys with Ansible, waits for the site to answer and only then pushes that commit to GitHub.

That way the public copy is never ahead of what's deployed. If GitHub had something Forgejo doesn't, the deployment stops instead of forcing anything.

Result

Every push, to the infrastructure or to this site, goes through CI, and its result reaches Element encrypted. Infrastructure changes are seen before merging and applied with the exact plan. This site goes from a push to the internet on its own, and GitHub stays as the shop window.

Next: let update-only plans apply on their own, while those that destroy or recreate keep waiting for my click.