Forgejo Git Server
Architecture and storage setup for my localized Forgejo instance.
My Git server is powered by Forgejo (a community-run Gitea fork) and backed by a local Postgres database.
The entire stack—the app, the SSH daemon it front-ends, and the underlying storage mount—runs entirely under a single, unprivileged local user: git. Absolutely nothing is owned by root, no processes run as root, and no part of the request path ever escalates privileges. Everything lives inside /home/git. If this user is ever somehow compromised, the blast radius is trapped strictly inside that home directory. It is a paranoid, highly compartmentalized setup, which is exactly how I like it.
To be completely clear: this single-user containment model is not a replacement for proper containerization (like Docker) or virtualization. I am doing this strictly because of my VPS's resource limitations. If I had the RAM and CPU overhead to spare, I would containerize this, but right now, the host needs every megabyte it can get.
Why S3 instead of EBS?
The "normal" way to back a Git server on a VPS is to just slap it on a local EBS volume. It is fast, simple, and POSIX-native. I deliberately ignored this advice, entirely because I hate capacity planning.
- The 2GB Ceiling: The EBS volume backing
/home/gitis capped at exactly 2GB. It is intentionally tiny—sized only for the Forgejo binary, config files, logs, and a local cache. - Infinite Growth: Git repositories (especially with LFS or long histories) grow indefinitely. Provisioning EBS for that means I either overpay for empty disk space upfront, or I get to experience the joy of resizing partitions at 3 AM when the disk fills up. I am trying to avoid operational overhead, not create more of it.
- The Trade-off: S3 has no ceiling. I can mirror massive repos without ever checking
df -hfirst. The obvious trade-off is latency: S3 over a FUSE mount is measurably slower than local NVMe, especially for operations that walk lots of small objects. But for my personal usage pattern, slightly slower cold reads are a price I am happily willing to pay to never think about storage limits again. Plus, I get AWS's absurd eleven-nines of durability essentially for free.
Rclone & Storage Architecture
All stateful data—the app directory, Git LFS objects, and the bare repositories themselves—lives in an S3 bucket. I mount that bucket directly into the local filesystem at /home/git/fjo-s3fs using rclone mount over FUSE.
I use rclone instead of s3fs-fuse or goofys because its VFS layer gives me a real, configurable local write-back cache, and it's heavily maintained.
Why user-space, and why lingering is required
Because I refuse to use root, the mount and Forgejo itself are user-level systemd units (systemctl --user).
The catch is that user-level systemd units normally die the second you log out. Since git is a service account that never actually logs in, its background services would never stay running. To fix this, I ran sudo loginctl enable-linger git. This is the one time root touches this stack. It simply tells the init system to treat the git user's manager as persistent infrastructure.
With lingering enabled, the mount and the web server survive reboots automatically. To manage them from my admin account, I just reach into the user's scope:
sudo systemctl --user -M git@ status fjo-s3fs
sudo systemctl --user -M git@ status forgejoCache Tuning
To stop Git from being unbearably slow over the network, the mount runs with a highly aggressive local VFS cache:
rclone mount fjo-s3fs:<bucket> /home/git/fjo-s3fs \
--file-perms 0755 \
--vfs-cache-mode full \
--vfs-write-back 5s \
--cache-dir /home/git/.local/state/fjo-s3fs-cache \
--vfs-cache-max-size 1500M \
--vfs-cache-max-age 24hHere is exactly what is happening under the hood:
--vfs-cache-mode full: The most aggressive mode. It pulls entire files to local disk on the first read/write. This is mandatory because Git loves to do random seeks inside pack files, which S3 inherently hates.--vfs-write-back 5s: Modified files sit locally for 5 seconds before being thrown to S3. It batches writes just enough without leaving a huge window for data loss if the server crashes.--cache-dir ...: Dumps the cache in~/.local/statefollowing standard XDG conventions.--vfs-cache-max-size 1500M: The hard cap. Notice how 1500MB is deliberately under my 2GB EBS limit? This ensures the cache itself can never accidentally nuke my server by filling the disk.--vfs-cache-max-age 24h: Kicks out stale data after a day, even if there is plenty of space left.--file-perms 0755: This is not a nicety; it is a functional requirement. Git hooks are executable scripts. Because S3 objects don't have Unix permissions, FUSE has to synthesize them. If I don't force0755, Forgejo's internal hooks lose their executable bits and blatently fail:
$ git push
Enumerating objects: 13, done.
Counting objects: 100% (13/13), done.
Delta compression using up to 8 threads
Compressing objects: 100% (6/6), done.
Writing objects: 100% (7/7), 8.35 KiB | 8.35 MiB/s, done.
Total 7 (delta 1), reused 0 (delta 0), pack-reused 0 (from 0)
remote: Checking connectivity: 7, done.
fatal: cannot exec 'hooks/pre-receive': Permission denied
To git.jelius.dev:jelius-sama/infra-project.git
! [remote rejected] main -> main (pre-receive hook declined)
error: failed to push some refs to 'git.jelius.dev:jelius-sama/infra-project.git'Git Compatibility
Git has a built-in paranoia mechanism: it refuses to search for a .git directory across filesystem boundaries to prevent weird edge cases. Because FUSE mounts look like entirely different filesystems to the OS, Git would naturally refuse to cross into my S3 mount.
I bypass this globally by setting this in the git user's system environment:
export GIT_DISCOVERY_ACROSS_FILESYSTEM=1This tells Git to relax and cross the boundary, because I know exactly what is on the other side.
Forgejo Binary & Execution
Forgejo is a single self-contained binary, but I refuse to overwrite it in place during upgrades like an animal. Instead:
- I download the versioned file (e.g.,
forgejo-11.0.7) to~/.local/bin/. - I symlink
~/.local/bin/forgejoto the current version. - The systemd service always points to the stable symlink.
Upgrading means dropping the new binary, flipping the symlink, and restarting. Rollbacks are instant. No half-written binaries, and no redeploying containers.
The service just invokes the binary in web mode:
/home/git/.local/bin/forgejo web --config /home/git/.config/forgejo/app.iniDirectory Structure
I don't hardcode S3 paths into Forgejo's config because tightly coupling the app to the mount point is messy. Instead, I use symlinks:
/home/git/.local/var/lib/forgejo→~/fjo-s3fs/forgejo(App data)/home/git/lfs→~/fjo-s3fs/lfs(Large File Storage)/home/git/repositories→~/fjo-s3fs/repositories(The bare repos)
Forgejo reads the paths on the left, completely oblivious to the fact that it is actually reading an S3 bucket over a FUSE mount. If I ever swap storage backends, I just update the symlinks. The app config never changes.