May 2026 · 5-part series: migrating CI/CD from GitHub to Codeberg
Our CI pipeline runs ~12-15 minutes end-to-end — the same as on GitHub Actions. But the details matter more than the headline number.
| Phase | Step | Duration |
|---|---|---|
| 1 | lint-backend | ~3 min |
| 1 | test-backend (8 workers) | ~7 min |
| 1 | test-frontend | ~2 min |
| 2 | build-backend | ~3 min |
| 2 | build-frontend-staging | ~2 min |
| 3 | deploy-staging | ~3 min |
Key difference: Backend tests run faster. On GitHub-hosted runners (2 vCPU), pytest's -n auto used 2 workers. On our spot pool (8 vCPU e2-highcpu-8), -n auto detects 8 CPUs and uses 8 workers. Test time dropped from ~20 minutes to ~7 minutes.
| Component | GitHub Actions | Forgejo on GKE |
|---|---|---|
| CI runners | Billed per minute | ~$3/month (spot, scale-to-zero) |
| Container registry | GHCR (free, bundled) | GAR (~$0.10/GB stored) |
| Runner compute | 2 vCPU per runner | 8 vCPU spot (~$0.01/hr) |
| Cold start | None (queues anyway) | ~2-3 min (pool scaled to zero) |
| Total monthly | $$$ (minutes add up) | ~$3/month, $0 when idle |
Scale-to-zero is the game changer. Our CI runner costs exactly $0 when nobody is pushing code. For a small team pushing 10-20 times per day, total CI compute is ~$3/month.
| Metric | GitHub Actions | Forgejo on GKE |
|---|---|---|
| CI availability | 99.5-99.9% (reported) | 99.95% GKE, 99.99% Cloud SQL |
| Status accuracy | "Operational" while broken | We own the runner — it works or we see why |
| Debug loop | File ticket, wait | kubectl logs, done |
| Queueing | Shared runners, minutes | Dedicated spot pool |
| Webhook reliability | Dropped, no ack | Codeberg or manual |
The key advantage isn't the SLA number — it's the debug loop. On two occasions during our GitHub era, deploys were blocked for hours by platform incidents the status page reported as "operational." Each time, we wasted hours debugging our own config. On our self-hosted runner, those hours would have been minutes.
postgresql:// on day onekubectl logs and Grafana on day onepostgresql:// URL on day one