---
title: "Self-hosted git with Forgejo (and why some repos never touch GitHub)"
description: "I set up my own git forge on the home server and within a month made it the default remote, keeping GitHub only for open-source work."
url: "https://michael.zazon.us/notes/self-hosted-git-with-forgejo-and-why-some-repos-never-touch-github"
published: "2026-06-04T12:00:00-04:00"
kind: "build-log"
subject: "homelab"
tags: ["git"]
---
I set up my own Forgejo server for personal projects, mainly for the privacy aspect.. not to mention that I already ran an always on server. It made sense for my home lab to have that with excess compute and disk capacity instead of paying for a hosted solution through GitHub or GitLab. I had heard about the Forgejo project as it being a fork of Gitea, and I was really intrigued to be able to run my own actions with my own hosted runners on my own infra and experiment with hosting something like that, taking my own backups, and really having a private experimentation area.

One of my colleagues runs Forgejo and they spoke highly of it, and I actually met one of the maintainers at a conference in NYC. I was really impressed with their mission and their expertise, so I decided to go with it, and it has been great so far.

Forgejo forked from Gitea in 2022 after a for-profit company acquired it. Now it's under the non-profit Codeberg e.V. and the [comparison page](https://forgejo.org/compare-to-gitea/) tells the story. As a single user, the features are near-identical so it came down to governance for me.

I chose SQLite over PostgreSQL, and the [database docs](https://forgejo.org/docs/latest/admin/database-preparation/) list all the options. Under 10 users, SQLite means one backup file and no maintenance. I can always migrate later.

I also chose the standard [Docker image](https://forgejo.org/docs/latest/admin/installation/docker/) over the rootless one because it's simpler for one user. The trade-off is every CLI command runs as the `git` user.

## A one-commit deploy

The entire deploy is a single commit to my home infra repository: an Ansible playbook (189 lines) and a compose file (26 lines). The playbook handles everything from the ZFS dataset to the monitor, including an admin user with a generated password that I change on first login. All configuration happens through `FORGEJO__section__KEY` environment variables, so there's no hand-edited `app.ini` file. Here's the relevant snippet (with host/port/volume lines trimmed):

```yaml
services:
  forgejo:
    image: codeberg.org/forgejo/forgejo:15
    container_name: forgejo
    network_mode: host
    restart: always
    environment:
      - USER_UID=1000
      - USER_GID=1000
      - FORGEJO__server__START_SSH_SERVER=true
      - FORGEJO__server__LFS_START_SERVER=true
      - FORGEJO__database__DB_TYPE=sqlite3
      - FORGEJO__database__SQLITE_JOURNAL_MODE=WAL
      - FORGEJO__security__INSTALL_LOCK=true
      - FORGEJO__service__DISABLE_REGISTRATION=true
      - FORGEJO__cache__ADAPTER=twoqueue
      - 'FORGEJO__cache__HOST={"size":100, "recent_ratio":0.25, "ghost_ratio":0.5}'
```

I picked the web and SSH ports to avoid clashing with any existing services and the host's sshd. The local DNS name is pointed at the server via an AdGuard Home rewrite, which I added manually in the UI. But when I first deployed, I got an error. Darn it.. Turns out Forgejo 15 doesn't like running the admin CLI as root.

```text
Forgejo is not supposed to be run as root.
```

The cost of the standard image: every `docker exec forgejo ...` command in the playbook runs as the `git` user, and the admin password is provided by Ansible, not me.

```yaml
    - name: Create admin user
      shell: >
        docker exec --user git forgejo forgejo admin user create
        --admin
        --username <admin>
        --email <admin-email>
        --password '{{ forgejo_initial_password }}'
        --must-change-password
```

```yaml
    - name: Generate initial admin password
      set_fact:
        forgejo_initial_password: "{{ lookup('password', '/dev/null chars=ascii_letters,digits length=24') }}"
```

Like every other service that runs on this box, Forgejo has its data on its own ZFS dataset rather than a Docker volume. Uptime Kuma checks it every 60 seconds and alerts me via Pushover if there are any issues. The Backrest pre-backup hook copies the database safely before restic runs.

[Diagram: Forgejo container on a ZFS dataset with Uptime Kuma monitoring, Backrest backup and snapshots](https://michael.zazon.us/notes/self-hosted-git-with-forgejo-and-why-some-repos-never-touch-github)

When I looked at the monitor wiring, I got a surprise: the playbooks insert Uptime Kuma monitors via SQL, but the "default" notification flag doesn't attach to those monitors. So the `monitor_notification` join table needs to be filled explicitly. Six monitors (Backrest, go2rtc, MagicMirror, Home Assistant, Mosquitto, Obsidian API) had never been bound to Pushover, so they were failing silently. Now the Forgejo playbook binds its own monitor and repairs any monitor missing Pushover.

[Image: Uptime Kuma's Forgejo monitor: up, checked every 60 seconds, about 2 ms response, and 100% uptime over 24 hours, 30 days and a year](https://michael.zazon.us/notes/self-hosted-git-with-forgejo-and-why-some-repos-never-touch-github)


## Five days with a broken backup

The backup hook first flushes Forgejo's queues, then uses `sqlite3 .backup` to make a copy that's guaranteed consistent even while Forgejo is writing. I kept `forgejo dump` only as a secondary measure because of its known bugs.

```bash
# sqlite3 .backup produces a consistent snapshot even during writes.
# Output is self-contained (no WAL dependency).

# Flush Forgejo queues to ensure data is written to disk before safe copy
if command -v docker &>/dev/null; then
    if docker exec forgejo forgejo manager flush-queues 2>/dev/null; then
        echo "OK: Forgejo queues flushed"
    fi
fi
```

I came back to harden on May 11 and it was worth it: the first thing I noticed was that my clone URLs were showing `localhost` because I'd never set `SSH_DOMAIN`, which the [config cheat sheet](https://forgejo.org/docs/latest/admin/config-cheat-sheet/) states is what sets the displayed clone URL.

```diff
       - FORGEJO__server__SSH_LISTEN_PORT=<ssh-port>
+      - FORGEJO__server__SSH_DOMAIN=forge.example
       - FORGEJO__server__SSH_PORT=<ssh-port>
```

The second issue is much worse. But.. ugh.. I had been pointing the hook at the wrong SQLite file since the day I deployed it. The database file for Forgejo is called `gitea/gitea.db` in the data directory. So the safe copy had been warning me every time it ran for five days that it couldn't copy the file, and the dataset in question had zero ZFS snapshots as a fallback. Luckily, it was just a one-line fix in the hook and a sanoid snapshot policy for the dataset.

```diff
-        ["forgejo"]="/<pool-mount>/forgejo/forgejo.db"
+        ["forgejo"]="/<pool-mount>/forgejo/gitea/gitea.db"
```

```ini
[<pool>/forgejo]
  use_template = production

[template_production]
  hourly = 24
  daily = 7
  weekly = 4
  monthly = 0
  autosnap = yes
  autoprune = yes
```

## Forgejo by default

From day one I knew the question of GitHub would be there. Whether I mirror or cut over completely would be a per-repo decision. Public repos might continue to live on GitHub for discoverability, for example. I figured I'd use Forgejo's built-in GitHub migration tool and look at [push mirrors](https://forgejo.org/docs/latest/user/repo-mirror/) for anything that stays. On May 14 I flipped the origin of my first existing repo to Forgejo. Three days later I did the opposite for my home infra repo: GitHub became the primary, with Forgejo mirroring it to guard against server loss. Two days after that I made my first repo Forgejo-only, with no GitHub copy at all.

At the end of May, when I moved to the new server, I decided enough was enough. There was no way I could clone private GitHub repos on the new box without any user intervention, so on June 1st I flipped the switch and made Forgejo-only the default, with the infra repo being the only exception that pushes to both. The very next day, I codified it into a rule: Forgejo is the default remote, and no GitHub push without checking first.

Currently, all of my repositories are privately hosted now on my own server. It makes me very comfortable that I can host and develop this way. There's a few small exceptions, like [this personal site](https://michael.zazon.us/notes/how-this-site-is-built) is hosted on GitHub as it's open-sourced. And some other community contributions that I make, I use GitHub because the open-source community is very important to me, and I like to contribute, give back on many projects. So GitHub ends up being somewhere where I do collaboration work, and anything that I feel needs to be released in the open-source community obviously gets put on GitHub.

Next up are runners. [Forgejo Actions](https://forgejo.org/docs/latest/user/actions/overview/) consumes GitHub Actions YAML, with some differences: you need a self-hosted runner, and the token is `GITEA_TOKEN` instead of `GITHUB_TOKEN`. I looked at deploying this in May, but the runner wasn't production-ready yet so I held off. Running my own actions on my own runners was the original reason I got interested in Forgejo, so that's what I'll set up next.