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 covers the one with the biggest job. Forgejo is the keystone of the platform. It is the canonical home of every repository, the trigger for CI, and the reason many other services exist. It is also where this series stops being aspirational, because real client work ships through this stack every day.

The post covers why Forgejo won, how the Git service and its runner are configured, and the mirror pattern that keeps GitHub useful without making it the source of truth. It ends with two honest post-mortems: a CI job that queued forever with no error, and the Redis that persisted nothing for weeks.

Why Self-Host Git, and Why Forgejo

The “why not GitHub” question deserves a straight answer, because GitHub is good. Three things decided it. Ownership: client and personal work lives canonically on hardware I control, and GitHub becomes a copy. Integration: a Git server on the platform network can trigger CI, feed automation, and use the same backup machinery as everything else. CI economics: a self-hosted runner turns CI minutes from a metered resource into a fixed cost that is already paid.

Among self-hosted options the shortlist was short. GitLab is the feature king, and its weight rejected it. Its own documentation wants 4GB or more of RAM for a comfortable install, which is a fifth of this machine for one service. Gitea is the lightweight classic. Forgejo is Gitea’s community-governed fork, the one Codeberg runs. It is API-compatible with Gitea, similarly light — it idles in the low hundreds of megabytes — and its Actions system deliberately speaks GitHub’s workflow syntax. That last property pays off later in this post. Community governance plus GitHub-compatible CI made it a comfortable bet.

The Stack Itself

The Compose definition is almost embarrassingly small, and that is 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 hide in those lines.

The image tag is an exact version, not :latest. A Git server that upgrades itself overnight is a Git server that greets you with a migration failure at 7am. Version changes here are deliberate acts: proposed, reviewed, and done with a fresh backup. 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 the platform’s backup discipline cannot see it. A bind mount under /opt/platform/data puts the repositories on the SSD tier, in the directory tree that the backup jobs and the nightly LXC snapshot already cover. A predictable path is easier to verify on restore day than a volume name you have to look up.

PostgreSQL is not in this file. Forgejo can use SQLite, and for one user that is arguably enough. PostgreSQL runs as a separate shared platform stack because other services (n8n, Immich) need it anyway. 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 next to PostgreSQL, because that is what you do. Every self-hosting guide pairs them. Caching is good, and queues are good. Redis ran for weeks with persistent storage, its own container, and a tile on the dashboard.

The July service review did a live check before it decided. Zero keys, and zero application connections. Nothing had used it. Forgejo at this scale is content with its internal caching, and no other service asked for Redis at all. It was retired that day, with its state parked in a rollback window and then removed.

It repeats Part 6’s finding in a different form. Redis went in because the architecture diagram in my head had a box for it, rather than because a service asked for one. It still had to be patched, monitored, and backed up for the whole time it sat unused.

The Runner, and the Job That Queued Forever

CI runs on forge-runner-01, a Forgejo Runner container. It polls the server for jobs and runs 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 first line has a story. The first real client-site CI run queued forever. It did not fail. It queued, silently, with no end. Forgejo’s scheduling model gives a job to a runner that advertises the job’s runs-on label. A runner that does not advertise ubuntu-latest never claims a job that asks for it. There is no error, no warning, and no “no runner matches this label” hint. The job 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. That unlocks the real prize: one workflow file that runs unchanged 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 configurations, and 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 at http://forgejo:3000. CI does not make a public round-trip to fetch code from a server on the same machine.

A runner that stops claiming jobs is otherwise invisible, so the repository carries a deliberately trivial smoke workflow. It prints the time, prints the platform, and exits green. It is a health check that nobody confuses with a real build failure. It earned its keep during a runner upgrade: 12.13.2 passed the smoke test and stayed, while 13.0.0 — released one day earlier with workflow-breaking changes — was held back deliberately.

The Mirror Pattern: GitHub Demoted, Not Deleted

Client repositories are canonical on Forgejo, and GitHub keeps two jobs: off-site backup, and deploy trigger for Cloudflare Pages, which cannot watch a private Forgejo instance. The wiring is Forgejo’s push mirror, configured once, server-side, for each repository. Every push to Forgejo replays to GitHub automatically. 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, and GitHub is never pushed to directly.

The alternative deserves a name, because it looks equivalent and is not. Dual push URLs on the origin remote make one git push reach both servers. I rejected them for a maintenance reason, not a technical one. Dual push URLs are per-clone client configuration. Every machine, every fresh clone, and every future collaborator must repeat them. Forget once, and the mirror quietly stops being a mirror. A server-side push mirror is configured one time and works from any machine. Both designs achieve the same sync. The server-side one has fewer places to forget it.

The first site through the pipeline found one more trap. The workflow pinned Node 20, and Astro 7 needs Node 22.12 or newer. So the GitHub half of the chain failed while Forgejo’s mapped node:22 container passed. One workflow file, two runtimes, and one pinned version that only satisfied one of them. Check the framework’s minimum before pinning.

Backed Up Like It Matters

A self-hosted Git server is a single point of failure unless the backup story is real. Forgejo gets the most careful treatment on the platform. Every six hours, an automation job stops the Forgejo container briefly, so that repositories and database come from the same quiet moment. It takes a full application dump and a separate PostgreSQL dump, restarts the service, uploads everything to MinIO object storage, and makes sure of every uploaded object before it reports success.

Stopping a service inside a script raises an obvious question: what restarts it if the script dies? The answer is a trap that runs on every exit path, plus a lock directory that stops two backups overlapping:

forgejo_was_stopped=0

cleanup() {
  exit_code=$?
  if [ "${forgejo_was_stopped}" -eq 1 ]; then
    docker start "${FORGEJO_CONTAINER}" >/dev/null || true
  fi
  docker rm --force "${watchdog_name}" >/dev/null 2>&1 || true
  rm -rf "${stage}"
  rmdir "${lock_dir}" 2>/dev/null || true
  exit "${exit_code}"
}
trap cleanup EXIT HUP INT TERM

# mkdir is atomic, so it doubles as the lock
if ! mkdir "${lock_dir}" 2>/dev/null; then
  printf 'A Forgejo backup is already running; refusing to overlap.\n' >&2
  exit 1
fi

Three things make this safe to run unattended. The trap covers EXIT as well as the signals, so Forgejo restarts even when the script fails halfway. The || true on the restart means a cleanup error cannot mask the original failure. And mkdir is atomic, which makes it a usable lock without any extra tooling. A separate watchdog container also restarts Forgejo if the whole script is killed before the trap can fire. The job is deliberately more careful than the other backups on the platform, because a lost Git server loses the history of everything else too.

Next in the Series

Forgejo works because pushes reach it from anywhere, over a public hostname, with no open router ports 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.

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