Part 7 of the series: Building a Self-Hosted AI Development Platform
5 min read

Building a Self-Hosted AI Development Platform — Part 7: Self-Hosted Git with Forgejo, PostgreSQL, and a CI Runner

Part 7 of the Forge series: why Forgejo over GitLab, the CI label mismatch that queues jobs forever, the push-mirror pattern that keeps GitHub useful, and the Redis that held zero keys

Part 7: Self-Hosted Git with Forgejo, PostgreSQL, and a CI Runner

Part 6 ended with a keep-list of services that all have jobs. This post is about the one with the biggest job of all. Forgejo is the keystone of the platform — the canonical home of every repository, the trigger for CI, the thing most other services exist to serve. It’s also where this series’ subtitle stops being aspirational: real client work ships through this stack daily.

This post covers why Forgejo won, how the Git service and its runner are actually configured, the mirror pattern that keeps GitHub useful without letting it be the source of truth — and two honest post-mortems: a CI job that queued forever with no error, and the Redis that spent weeks faithfully persisting nothing.

Why Self-Host Git — and Why Forgejo

The “why not just GitHub” question deserves a straight answer, because GitHub is genuinely good. Three things tipped it: ownership — client and personal work living canonically on hardware I control, with GitHub demoted to a copy; integration — a Git server on the platform network can trigger CI, feed automation, and be backed up by the same machinery as everything else; and CI economics — a self-hosted runner turns CI minutes from a metered resource into a fixed cost that’s already paid.

Among self-hosted options, the shortlist was short. GitLab is the feature king and was rejected on weight alone — its own docs want 4GB+ of RAM for a comfortable install, which is a fifth of this entire machine for one service. Gitea is the lightweight classic. Forgejo is Gitea’s community-governed fork (the one Codeberg runs), API-compatible with it, similarly light — idling in the low hundreds of megabytes — and its Actions system deliberately speaks GitHub’s workflow syntax, which pays off enormously later in this post. Community governance plus GitHub-compatible CI made it the comfortable bet.

The Stack Itself

The Compose definition is almost embarrassingly small — which is rather the point:

services:
  forgejo:
    image: codeberg.org/forgejo/forgejo:15.0.5
    container_name: forgejo
    restart: unless-stopped
    environment:
      USER_UID: 1000
      USER_GID: 1000
    ports:
      - "3003:3000"   # web UI, reverse-proxied by Caddy
      - "2222:22"     # SSH for git push/pull
    volumes:
      - /opt/platform/data/forgejo:/data
    networks:
      - platform

networks:
  platform:
    external: true

Three decisions hiding in those few lines:

The image tag is pinned to an exact version, not :latest. A Git server that silently major-upgrades itself overnight is a Git server that eventually greets you with a migration failure at 7am. Version bumps here are deliberate acts — proposed, reviewed, and done with a backup fresh. (Part 9 covers who does the proposing.)

Data is a bind mount to /opt/platform/data/forgejo, not a named volume. Named volumes are Docker’s preferred idiom, but they hide state inside /var/lib/docker where it’s invisible to the platform’s backup discipline. A bind mount under /opt/platform/data puts Forgejo’s repositories on the SSD tier, in the exact directory tree the backup jobs and the nightly LXC snapshot already cover. Boring paths beat clever abstractions when restore day comes.

PostgreSQL isn’t in this file. Forgejo would happily use SQLite, and for one user that’s arguably enough — but PostgreSQL runs as a separate shared platform stack because other services (n8n, Immich) need it anyway, and one well-backed-up database engine beats four embedded ones. Proper dumps, one upgrade path, one thing to monitor.

The Redis Post-Mortem

The original “development stack” sprint installed Redis alongside PostgreSQL, because that’s what you do — every self-hosting guide pairs them, caching is good, queues are good. It ran for weeks with persistent storage, its own container, a tile on the dashboard.

The July service review ran a live check before deciding its fate: zero keys, zero application connections. Nothing had ever used it. Forgejo at this scale is perfectly happy with its internal caching; nothing else on the platform ever asked for Redis at all. It was retired on the spot — state parked under a rollback retention window, then gone.

The lesson is a sharper version of Part 6’s: don’t install infrastructure because the architecture diagram in your head has a box for it. Install it when a service asks for it. A cache with no clients is pure liability — patched, monitored, backed up, for nobody.

The Runner, and the Job That Queued Forever

CI runs on forge-runner-01, a Forgejo Runner container that polls the server for jobs and executes each one in a fresh Docker container. Its label configuration is three lines that took real pain to get right:

labels:
  - "ubuntu-latest:docker://node:22-bookworm"
  - "docker:docker://node:22-bookworm"
container:
  network: platform

The story behind the first line: the first real client-site CI run queued forever. Not failed — queued, silently, indefinitely. Forgejo’s scheduling model is that runners claim jobs whose runs-on label they advertise, and a runner that doesn’t advertise ubuntu-latest simply never claims a job asking for it. There’s no error, no warning, no “no runner matches this label” hint. The job just waits, politely, for a runner that will never come.

The fix is that label mapping: the runner advertises ubuntu-latest and satisfies it with a node:22-bookworm container. Which unlocks the real prize — one workflow file that runs unmodified on both Forgejo Actions and GitHub Actions. The same runs-on: ubuntu-latest selects GitHub’s hosted runner on one platform and the mapped container on the other. No forked CI configs, no drift between them.

The container.network: platform line is the same trick as everywhere else in this series: job containers join the shared network, so actions/checkout reaches the Git server as http://forgejo:3000 — no public round-trip from CI to fetch code from a server sitting on the same machine.

And because a runner that silently stops claiming jobs is otherwise invisible, the repo carries a deliberately trivial smoke workflow — print the time, print the platform, exit green — as a health check that never gets confused with a real build failure. (It earned its keep during a runner upgrade: 12.13.2 passed smoke and stayed; 13.0.0, released a day earlier with workflow-breaking changes, was deliberately held back.)

The Mirror Pattern: GitHub Demoted, Not Deleted

Client repositories are canonical on Forgejo — but GitHub still has two jobs: off-site backup, and deploy trigger for Cloudflare Pages, which can’t watch a private Forgejo instance. The wiring is Forgejo’s push mirror: configured once, server-side, per repository. Every push to Forgejo is automatically replayed to GitHub. The full chain, from the build notes:

push to Forgejo (origin)
  -> Forgejo Actions CI builds the site
  -> push mirror syncs to GitHub
    -> GitHub Actions runs the same workflow file
    -> Cloudflare Pages deploys from the mirror

One git push from any machine drives all of it. GitHub is never pushed to directly.

The alternative worth naming, because it looks equivalent and isn’t: dual push URLs on the origin remote, so one git push hits both servers. Rejected for a maintenance reason, not a technical one — dual push URLs are per-clone client config, which every machine, every fresh clone, every future collaborator must remember to repeat. Forget it once and the mirror silently stops being a mirror. The server-side push mirror is configured exactly once and works from any machine forever. When two designs tie on function, the one with fewer things to remember wins.

The first site through the pipeline surfaced one last footgun worth recording: the workflow pinned Node 20, and Astro 7 requires Node ≥ 22.12 — so the GitHub half of the chain failed even as Forgejo’s mapped node:22 container sailed through. Same workflow file, different runtimes. Pin your versions to your framework’s floor, not to muscle memory.

Backed Up Like It Matters

A self-hosted Git server is a self-inflicted single point of failure unless the backup story is real. Forgejo’s is the most paranoid on the platform: every six hours, an automation job briefly stops the Forgejo container — so repositories and database are captured at the same quiet instant — takes a full application dump plus a separate PostgreSQL dump, restarts the service, uploads everything to MinIO object storage, and verifies each uploaded object before reporting success. A watchdog restarts Forgejo if the job dies mid-window. Paranoid, layered, and tested — the way you’d want the thing holding all your code to be treated.

Next in the Series

Forgejo works because pushes reach it from anywhere — over a public hostname, with no open ports on the router and no exposed home IP. That machinery is Part 8: Cloudflare Tunnel and Caddy, the wildcard-plus-allowlist routing pattern, Cloudflare Access as the front door — and the day a “quick DNS fix” for Git SSH took every public service offline at once.

Next: Part 8 — Publishing Services Safely with Cloudflare Tunnel and Caddy