// Personal website of Chris Smith

Migrating from GitHub to Forgejo

Published on

When Microsoft bought GitHub in 2018 my kneejerk reaction — like so many others — was to start looking for alternatives. For a while I self hosted a Gitea instance but I never totally bought into it: some repositories I still pushed to GitHub, some I pushed to Gitea and they got mirrored, and I ended up causing myself problems when I got the two confused. Part of the problem was that the GitHub UI was faster and cleaner than Gitea’s at the time; using Gitea felt like a chore compared to GitHub. I ended up not maintaining it and eventually binning it and just going back to GitHub.

A screenshot of the GitHub error page, featuring an angry-looking Unicorn.

An all-too familiar unicorn

Fast forward eight years, and GitHub is about what we all imagined when Microsoft bought it. If you take the most pessimistic way of counting, they have zero nines of reliability1. If you take the most generous, they have a single nine. That’s around 30 minutes of downtime every day. It feels like it must be more than that, given how many times you see the damned unicorn.

Uptime aside, they’ve crowbarred LLMs in all over the place; they don’t have the time to maintain official GitHub actions; the ones they do maintain have Copilot running roughshod all over them2; actions themselves are slow to run3, often have weird transient failures, and there are so many footguns that I’m pretty sure GitHub actions should be an entire category in the CWE top 10.

Speaking of security, while I was drafting this post a remote code execution was reported and the details are… impressive. The actual security issue was a pretty stupid oversight. Those happen. But they end up being able to execute code as a globally shared git user with access to all the other repositories on the node. What? I can’t quite get my head around it. No sandboxing, no containers, it just runs as a git user?

So, yeah, I’m not a fan of GitHub in its current state. A few months back I took the plunge and set up Forgejo, a fork of Gitea where they did radical things like add tests and ensure that it’s community operated not twisted for commercial use. Forgejo also potentially has some fun security problems4, but the way I host it mitigates more-or-less all problems. It’s also slightly less shocking when an open source project has lapses in basic security versus a $2 trillion corporation.

It’s taken a bit of doing, but I’m very happy with the setup I have.

The basic setup

I run all my server software using Docker, and Forgejo is no exception. They have decent documentation on how to get it running, and publish rootless images which is nice (and diminishes my urge to make an artisanal version myself). You can set configuration options using environment variables, which is perfect for containers, although the names can end up a bit unwieldy. Like this thing: FORGEJO__repository.signing__SIGNING_NAME=Chris Smith. It gets the job done, but the environment-to-ini mapping is pretty ugly.

I didn’t want to expose Forgejo publicly, as I wanted to avoid having to try and secure it5, or dealing with bots scraping it, or users signing up, and so on. I wanted somewhere to store my git repositories, handle CI for me, and mirror them somewhere public for other people to see and interact with. If you’ve read some of my other posts you’ll probably guess where this is heading: Tailscale. Alongside the Forgejo container, I run a Tailscale instance with a serve.json that covers both the web frontend and the SSH listener used for git operations:

{
    "TCP": {
        "22": {
            "TCPForward": "forgejo:2222"
        },
        "443": {
            "HTTPS": true
        }
    },
    "Web": {
        "${TS_CERT_DOMAIN}:443": {
            "Handlers": {
                "/": {
                    "Proxy": "http://forgejo:3000"
                }
            }
        }
    },
    "AllowFunnel": {
        "${TS_CERT_DOMAIN}:443": false
    }
}

This allows me to interact normally with Forgejo from any of my devices or servers (which all run Tailscale), while not exposing it to the Internet at all. It also handles the TLS certificates automatically, which saves me setting up something else to do that.

For CI, I run the Forgejo runner image on the same server, and gave it a dind container to use to actually run the workloads. dind is the cutesy name for Docker-in-Docker, basically a Docker daemon running inside a Docker container. That keeps the CI workload separated nicely from the “production” workload running on the server, which is nice from both a security and a monitoring point of view.

The uncanny valley of actions

Forgejo actions are basically like GitHub actions: they run a series of steps using one or more container images. You define those steps in the exact same clunky YAML format as with GitHub. The big difference between them is that GitHub defaults to using an absolutely massive base image filled with out-of-date preinstalled software, and Forgejo leaves that for the administrator to configure. You could, of course, just use the same image as GitHub, but lugging around a multi-gigabyte image that doesn’t even have recent versions of the software I want isn’t really my style. Instead, I set about using a plain debian image as the base, and then did a face-palm when I tried to run a “normal” action and remembered they’re all written in JavaScript, so expect node to exist.

Accommodating JavaScript isn’t really my style, either, so I did the very sensible thing of writing my own suite of actions in Go. Being written in Go means they can be compiled statically, published as a container, and manage their own very limited dependencies. For example, the checkout action is built into an alpine container with git added, while the dockerbuild action uses the buildah image as a base. Each image has the tools it needs, pinned to a recent version, and nothing more. This is very much not a sensible approach to take for most people, but it made me happy. With six basic actions, I could replace almost all the ad-hoc workflows I had in GitHub6.

Other than the base images and my self-inflicted JavaScript machinations, everything works the same as GitHub. But faster. So much faster. Most of my CI workflows finish on Forgejo before GitHub would even have allocated a runner to the job7. And I can actually view the logs without them glitching out.

Step aside Dependabot, here comes Renovate

One of the boons and/or banes of hosting code on GitHub is access to Dependabot8. It’s a tool that monitors your dependencies, and automatically submits PRs when new versions are available. It also does security alerts, but they’re comically bad for Go at least9. I do like having dependencies automatically handled, especially with a cooldown, which is how I had Dependabot configured before moving.

To replace it, I’ve configured Renovate, an open source alternative. I’d seen it used a bunch before: both by projects on GitHub who prefer it over Dependabot, and by people using GitLab and other platforms where Dependabot isn’t. It mostly does the same things, but Renovate has a few very nice extras.

Firstly, it supports transcluding config from another repo. So in each of my many, many projects, I just have this stub of a renovate config:

{
  "$schema": "https://docs.renovatebot.com/renovate-schema.json",
  "extends": [
    "local>meta/renovate"
  ]
}

Then the actual config lives in a default.json in the meta/renovate repository. I mirror that repository to GitHub, if you want to have a look. This is an amazing feature. When I enabled cooldowns in my Dependabot config it was a slog to go through all my active repositories and make the same change over and over again. With Renovate I can configure that centrally. As far as I know Dependabot can’t do anything like that, even for enterprises.

Within that central config I use some of the other nice features. It has the ability to auto merge changes. I use this to automatically accept changes from trusted projects, or my own libraries:

    {
      "matchManagers": ["gomod"],
      "matchPackagePatterns": [
        "^github\\.com\\/csmith\\/",
        "^chameth\\.com\\/"
      ],
      "matchUpdateTypes": ["minor", "patch"],
      "automerge": true,
      "minimumReleaseAge": "0 days"
    },
    {
      "matchManagers": ["gomod"],
      "matchPackagePatterns": [
        "^github\\.com\\/csmith\\/",
        "^chameth\\.com\\/"
      ],
      "matchUpdateTypes": ["major"],
      "minimumReleaseAge": "0 days"
    },

This takes any minor or patch release of my libraries, and automerges it with no cooldown. For major version changes it overrides the cooldown but doesn’t automerge. To automerge Dependabot PRs you have to write an action, implement that logic yourself, and hope you’ve successfully avoided all the footguns inherent in that.

Speaking of major version updates, Renovate also has an option to automatically update the imports in Go packages when it’s offering a major update. In the config I have:

"postUpdateOptions": ["gomodTidy", "gomodUpdateImportPaths"],

Which means that every time it does an update it’ll run go mod tidy, and it’ll update the import paths. In Go, v2 of a module has a different import path to v1 (so they can coexist if transient dependencies need different versions), so usually you have to go through and find and replace those paths. Renovate handles that for me now! I still have to address whatever breaking changes are in the new version, but there’s a lot less grunt work.

It can also run custom commands10, so for example in my legotapas project I have this extra snippet in the renovate.json:

  "postUpgradeTasks": {
    "commands": [
      "sh -c 'rm plate_*.go'",
      "go run ./cmd/generate"
    ],
    "executionMode": "branch",
    "fileFilters": ["*.go"]
  }

I won’t go into the gory details, but this handles automatically regenerating a bunch of files that change every time a certain dependency is updated. Previously I had a separate action to do that on GitHub; just being able to drop commands in the config file is much nicer.

To actually run Renovate, I simply have a workflow in Forgejo on a schedule that uses their official Docker image. It also triggers if I update the config, which is nice when I add a new rule, as it’ll immediately update the open PRs to comply. Overall, Renovate has felt like a pretty big quality-of-life upgrade.

Odd problems

Not everything has been smooth sailing. Sometimes when Renovate automerged a change, it would just… disappear. The PR was closed as merged, but the commit never landed on master. It wasn’t a huge issue as the next Renovate run would just recreate it, but it would then disable the automerge and I’d get confused as to why. It turns out there was a major problem with how I had everything set up. Renovate was telling Forgejo to automerge when the required checks pass, but I had no branch protection rules set up in Forgejo, so as soon as the PR was created it was eligible to merge. As the CI was so fast I hadn’t noticed that the automerges were happening before the CI results were posted.

The disappearing commits were because the repository was getting into a bad state when Renovate tried to automerge multiple changes instantly in the same repo. Forgejo has a bunch of maintenance commands that helped to fix that problem, and I then wrote a little script to enable branch protection rules so the CI would actually run. The automerging has been smooth since.

Another issue I had early on was that merging would fail because the GPG keyring was locked. I had Forgejo configured to sign all the automatic commits, so you can verify what commits came from my infrastructure. Forgejo is the only process using the keyring, so I wasn’t sure why it was becoming locked. It turns out that GnuPG uses the hostname as part of the lockfile, and because Forgejo was running in Docker it was getting random hostnames based on the container ID. Explicitly setting a hostname made GnuPG and by extension Forgejo much happier.

Webhooks and updating

Forgejo has this amazing feature: you can add a webhook that’s called for all your repos. GitHub, for some reason, supports this for organisations but not people. Even though the two are treated the same in a lot of places. It doesn’t seem like it would be hard, and it’s such an obvious thing to want, but… no. I wrote a whole tool to add webhooks for me to get around this. Now I don’t need it, Forgejo does the right thing!

One particularly interesting use of these webhooks has been making my Docker containers auto-update when a new image is pushed. I previously had a webhook receiver for a few projects that would update them automatically, but GitHub package notifications were extremely laggy. Every other webhook fired within a minute or so of the event happening, but package webhooks would sometimes take 30-60+ minutes to fire. I opened a support case with GitHub and it got escalated to engineering and fixed about 10 weeks later11. It was better for a while, and then got worse again. Trying to do continuous deployment with a 30 minute random lag was frustrating.

So now I had reliable webhooks, but the update process was a bit gross. I have a hangup about internet-facing services not having access to the Docker socket. It just feels like too big a security risk. So I had a process that received hooks, and then another that figured out if containers needed to be updated and restarted them. Adding a new container to the system meant recompiling the second process, which was a massive faff.

Fortunately, I was saved from this mess by my friend Greg. He wrote adze, a tool that receives webhooks and uses Docker Compose to update matching containers. He wrote a blog post about it. It’s all one process, but that’s no longer a blocker for me because the webhooks can be delivered privately from my Forgejo instance without exposing adze to the internet. It finds matching containers automatically based on the images they’re using12, and combined with Forgejo not forcing me to add a webhook to every single repository makes my life so much easier. I write some code, push it to Forgejo, and within a minute or so it’s running on my dev instance.

I have IRC notifications set up for when adze does updates, and I still have some old ones that announce GitHub package webhooks. I keep seeing things like this and chuckling:

15:44:42 <@ircjag> [ADZE] chameth.com: pending updating git.yak-wall.ts.net/public/chameth.com
15:44:45 <@ircjag> [ADZE] chameth.com: success updating git.yak-wall.ts.net/public/chameth.com
17:11:54 <@ircjag> [GHCR] Container pushed to csmith/chameth.com:dev.

I pushed a change at 15:43, Forgejo built it and pushed the image (to both its own registry and to GitHub), then at 15:44 adze updated the container to the new version. An hour and a half later GitHub gets around to delivering its own webhook.

Mirroring and PRs

The final piece I had to figure out was how to actually interact with people. Most of my projects are small and don’t get a massive amount of public contributions, but the option should still be there. And ideally not involve anyone having to figure out how to e-mail patches13.

Mirroring outwards is simple: Forgejo supports it natively. Whenever I push to Forgejo, it pushes out to the corresponding repo on GitHub. I’ve also started mirroring some projects to Codeberg, to avoid having GitHub as my single “public” presence. But what happens when I get a pull request? If I merge it in the GitHub web UI, Forgejo will push its own branch over the top and get rid of it14. I could pull the changes, merge them, and push the result to Forgejo but then I don’t get the benefit of any CI.

I ended up writing a small private tool that deals with this for me. I give it a GitHub PR, and it effectively imports it into Forgejo for me. I created a prs organisation, and it’ll create a fork of the project in that org if it doesn’t exist. Then it pushes the PR contents to a branch, and creates a PR from the prs repo into the main one. It runs as a user which can write to the PRs org, but only submit pull requests to public repositories outside of it.

When the PR is imported, I can review it in the Forgejo UI, and then I can approve the CI workflows for that particular PR. This gives me some extra defence-in-depth in case anyone is trying to abuse one of those action footguns. Once the CI passes, I can merge it as normal in the Forgejo UI, it gets mirrored out to GitHub, and GitHub marks the PR as merged automatically. It’s not the most elegant solution, but the actual workflow is pretty smooth, especially for the low volumes of PRs I get.

With that, I have Forgejo set up to do everything I need from a git host, and I’m no longer hamstrung every time GitHub goes down.


  1. This way of counting isn’t particularly fair, but it’s somewhat realistic. GitHub break out their uptime for things like git operations, pull requests, actions, webhooks and packages. Realistically if any of those independent things are down then my overall workflow doesn’t, well, work. ↩︎

  2. To be clear, I’m not objecting to the use of Copilot here. It’s a tool. But if you use a tool to do something fairly simple and it spews out 35 commits, including at least 5 reverts, then either it’s a terrible tool or you’re using it very wrong. I didn’t hunt down this example, I came across it while trying to debug something and wasted a bunch of time trying to figure out what on earth was going on. ↩︎

  3. You could self-host runners, which may help, except they’ve already threatened to charge you for the privilege of bringing your own runners↩︎

  4. If people could stop finding RCEs in git services for a few days so I can stop redrafting this intro, that’d be great. ↩︎

  5. I’m feeling pretty smug about this decision after reading about the security issues. ↩︎

  6. There’s one holdout: my dockerfiles repo which is a very special snowflake with a very complex workflow and I’m still deciding how to handle it. ↩︎

  7. Which is to be expected, given I’ve got a server dedicated to one person, and they’re trying to serve however-many million users at once. That doesn’t make it any less nice, though! ↩︎

  8. I’d link to it but there’s nowhere really good to link to. It’s just been subsumed into the ‘security’ functions of GitHub, along with a bunch of copilot nonsense. ↩︎

  9. The go project maintain a vulnerability database that includes details about which exact symbols are affected, and the govulncheck tool uses this to only report security issues in code that’s actually used. Dependabot doesn’t do that: it constantly alerts about code that’s not imported, so won’t ever be shipped. ↩︎

  10. In the default config it doesn’t allow anything of the sort, of course. You can whitelist individual commands, but because this is a private server I can merrily whitelist .* and do whatever I like. ↩︎

  11. The turnaround time isn’t particularly impressive, but the fact that they kept the support case open, and the (human!) support agent updated me several times is. I don’t have a lot of good things to say about GitHub, but I don’t think I’ve ever had a support experience that good from a company of a similar size. Especially as I wasn’t a paying customer at the time. ↩︎

  12. Why didn’t I do that in the first place? It seems so obvious now… ↩︎

  13. I really like the idea of an e-mail based workflow, but I absolutely hate actually doing it. ↩︎

  14. This is what happened to me a lot when I was half using Gitea. ↩︎

This content is also published/discussed on these external sites:

See the colophon for information on how personal data is used and retained when submitting this form.