Cheers.dev Forgejo Architecture
I recently started hosting a Forgejo instance at cheers.dev for myself, some friends, and our AI agents.

This is not a dramatic GitHub replacement post. GitHub is still my main place for code, and that is not changing any time soon. What I wanted was smaller: a Git forge I own, can reason about, and can let friends and agents use without turning it into a platform engineering department.
I also wanted a place where AI agents can have their own limited accounts. I can manage those accounts directly, and they won’t burn through my GitHub CI minutes while experimenting. More on that later.
The setup is fairly straightforward, but a few decisions came with tradeoffs I wanted to write down before I forget the reasoning.
Goals
The main goal was private Git hosting for a small group, with enough CI to be useful.
More specifically, I wanted:
- normal Git over SSH behavior
- a web UI for repos, issues, releases, and users
- Forgejo Actions for basic CI
- LFS and artifact storage that would not fill the main disk immediately
- backups that are good enough for real use (the full backup writeup is another post)
- admin access that is not hanging out on the public internet
I’m trying to balance data safety, service robustness, cost, and operational sanity.
High level setup
The core Forgejo instance runs on a DigitalOcean droplet. Forgejo, Caddy, and Litestream are managed with Docker Compose.
The public shape looks like this:
cheers.devpoints at a reserved DigitalOcean IPv4 address- Caddy listens on
80and443 - Caddy handles Let’s Encrypt and proxies to Forgejo on port
3000 - Forgejo exposes Git SSH on public port
22 - host admin SSH is available over Tailscale on port
2222
I’ve been experimenting with SSH proxying for Caddy as part of another project, so Caddy may take over the reverse proxying for Git SSH too. That would make access control and protection a bit easier.
There is also a separate DigitalOcean droplet for the Forgejo runner. The runner connects back to Forgejo and runs jobs using Docker. This gets the project off the ground, but future platform-wide runners will likely run on other providers over Tailscale.
Build jobs are where you invite project code to execute on your infrastructure. Keeping that away from the core Git host feels like a sane default. Users can still host their own runners if they need more CI time or weird setups.
Why Forgejo
Forgejo hits the sweet spot for what I wanted here.
It gives me repositories, users, SSH, Actions, packages, LFS, releases, and a decent web UI without needing to run a much larger Git platform. For a small instance, that matters. I don’t need every possible enterprise knob. I need the parts I’m actually going to use.
It also works well enough with SQLite that I didn’t feel the need to add a managed database or run Postgres on a second host.
That sounds like a small thing, but reducing the number of moving parts is one of the easiest ways to make self-hosted services more survivable. The best operational burden is the one you never add.
SQLite and Litestream
Forgejo is using SQLite with WAL mode.
For a small friends-and-agents instance, SQLite is totally reasonable. It’s a small service with predictable usage, not a public forge with thousands of users, and SQLite keeps the deployment simple.
The database is replicated with Litestream to DigitalOcean Spaces. That keeps the database recovery window small without adding a full database server to the design.
I’m intentionally not going deep on the backup strategy in this post. The short version is:
- Litestream replicates the SQLite database
- DigitalOcean Spaces stores LFS, packages, attachments, repo archives, Actions logs, and Actions artifacts
- DigitalOcean block volume snapshots cover the remaining filesystem data
- occasional Forgejo full dumps stored outside DigitalOcean
Git repository storage still lives on the attached block volume under /mnt/data/forgejo. The bulky object-ish data goes to Spaces. The database gets its own replication path.
This is one of those setups that is simple enough to explain, which is usually a good sign.
Caddy in front
Caddy handles the public HTTP side of the service.
I use Caddy for small deployments like this because the TLS story is easy and the config stays readable. For cheers.dev, Caddy terminates TLS with Let’s Encrypt and reverse proxies to Forgejo inside the Compose network.
There are a couple Git-hosting details in the config:
- request bodies are allowed up to
5GB - reverse proxy timeouts are extended for Git and LFS operations
- access logs are written as JSON for fail2ban to read
Those timeout and body size settings save you from debugging annoying half-failed pushes later.
Public Git, private admin
Forgejo SSH is exposed on public port 22.
I considered using a non-standard port, but for Git hosting I think the normal port is the better tradeoff. Users expect git@cheers.dev:org/repo.git to work without remembering extra connection details.
Host administration is different. Admin SSH is only reachable over Tailscale on port 2222, with ACLs restricted to my account.
So the public surface area is basically:
- web traffic through Caddy
- Git SSH through Forgejo
Everything else is operational access and goes through the tailnet.
There is also fail2ban on the host. It watches Forgejo auth failures and common scanner paths in the Caddy logs. The Docker footgun is that bans need to be applied through the DOCKER-USER iptables chain, because Docker published-port traffic can bypass the normal INPUT chain path.
That is a fun little footgun. By fun, I mean the kind where everything logs correctly while not actually blocking the traffic you thought it was blocking.
Object storage for the bulky bits
Forgejo is configured to use DigitalOcean Spaces for object storage.
That includes LFS objects, packages, attachments, avatars, repo archives, Actions logs, and Actions artifacts. Those are exactly the things I don’t want slowly filling the main filesystem while I pretend I will remember to check disk usage later.
The block volume still holds the Git repositories and Forgejo data directory. That keeps the filesystem-backed parts of Forgejo where Forgejo expects them, while pushing the blob-like data into blob storage.
Actions runner on a separate droplet
The Forgejo runner lives on its own DigitalOcean droplet.
It uses the Forgejo runner image and has access to the host Docker socket. That model is convenient, but it’s also exactly why I didn’t want it on the same machine as the core Forgejo service.
At this point you may already be cringing at the Docker socket mount, and you’re totally correct. For this instance, the runner is for trusted repos and a small group of users. If that changes, the runner model should change too.
The current runner setup is pretty primitive. Two parallel runs are enough to get started, but it is not the long-term plan. I want to overhaul this with dedicated ephemeral VM runners, mostly hosted in my homelab on an isolated VLAN. That probably gets its own post.
For now, the separate runner host gives me a useful boundary without adding too much complexity.
Customizing the instance
I also spent a bit of time making the instance feel like its own thing.
Forgejo supports custom assets and templates, so the deployment includes custom logo assets, fallback avatars, and a Dracula/Alucard theme. There is also a small template override that adds an About link in the top navigation.
I don’t intend to change Forgejo’s core source. Future enhancements should mostly come from integrations (OIDC, maybe?) rather than patches to Forgejo itself.
This part is not essential operationally, but I think it matters. If this is a place friends and agents are going to use, it should feel a little intentional. Not fancy. Just not like a forgotten staging environment.
What I still want to improve
There are a few follow-ups on my list:
- run a full restore drill
- harden the fail2ban configurations a bit
- add a status page (maybe we can beat GitHub’s uptime?)
- decide what I want to do with IPv6
- add it to my observability stack
I have a bunch of things I want to add: more runners, OIDC with user-based invites, and subaccounts for bots. Before that, I need to prove out recovery and get observability in place.
Final shape
The thing I like about this setup is that each component has a clear job.
Forgejo is the forge. Caddy handles public HTTP and TLS. Tailscale handles private admin access. Spaces handles bulky object storage. Litestream handles SQLite replication. The runner is off on its own machine where it can be useful without sitting directly beside the core service.
None of that is especially fancy. That’s the point.
cheers.dev is small infrastructure, but it is real infrastructure. It has public access, CI, object storage, mail, backups, custom branding, and a security posture I can keep improving over time.
That’s a pretty good starting point.