Run Your Own Git: A Private Forge You Control
A private code host on a machine you own, not a corporate platform that reads your repositories and holds the account. GitHub is Microsoft. This is the alternative.
GitHub is Microsoft. GitLab answers to its investors. Every private repository, every draft, every list of names and plans you have not deliberately published sits on a corporate platform that reads it, sets the terms and can suspend the account or hand the contents over when it is told to. For public work that trade can be fine. For anything private, for organising, for a project that should not depend on a company's goodwill, it is worth owning the box instead. Forgejo is the software Codeberg runs on, a light community fork of Gitea: pull requests, issues, the web UI, everything you expect from a code host, in one small container a mini-PC runs without noticing. You still push the public parts out when you want an audience. The difference is the default flips: private by you, public only on purpose.
What you need
A small always-on machine. Anything that stays powered: a mini-PC, an old desktop, a low-end home server. Forgejo runs on SQLite in one container, so it is tiny. A spare gigabyte of RAM is plenty, plus disk to hold your repositories.
A domain name you control, so the forge answers on something like git.example.com.
A free Cloudflare account, for the tunnel. No open ports, no exposed home address.
podman (Docker works the same, same flags). Linux assumed throughout.
The pattern, once more
Every service on this machine goes up the same way. Three moves, no exceptions:
- A container bound to a loopback port. It listens on
127.0.0.1only, nothing on the open network. - A dedicated Cloudflare tunnel that fronts that port at a public name. The tunnel dials out to Cloudflare, so there is no inbound port to open on your router and your home IP never appears in DNS.
- A small systemd user unit so both come back after a reboot with no hands on the keyboard.
That is how the three sites on this box stay up. Learn it once and every new service is the same three steps: a website, a forge, whatever is next. What follows is that recipe run one more time, for git.
Step 1, the container
A compose.yml. It pins a major version, keeps everything on a loopback port and hardens the defaults so the forge is private from the first second:
services:
server:
image: codeberg.org/forgejo/forgejo:11 # pin a major tag, never :latest
environment:
USER_UID: "1000"
USER_GID: "1000"
TZ: "Europe/Dublin"
# Skip the web installer, generate config from these env vars:
FORGEJO__security__INSTALL_LOCK: "true"
FORGEJO__server__DOMAIN: "git.example.com"
FORGEJO__server__ROOT_URL: "https://git.example.com/"
FORGEJO__server__HTTP_ADDR: "0.0.0.0"
FORGEJO__server__HTTP_PORT: "3000"
# HTTPS git only. The tunnel carries HTTP, so there is no SSH to expose:
FORGEJO__server__DISABLE_SSH: "true"
FORGEJO__server__START_SSH_SERVER: "false"
# You create the accounts by hand. No public signups, nothing browsable logged out:
FORGEJO__service__DISABLE_REGISTRATION: "true"
FORGEJO__service__REQUIRE_SIGNIN_VIEW: "true"
# New repos are private unless you say otherwise. No built-in CI runners:
FORGEJO__repository__DEFAULT_PRIVATE: "private"
FORGEJO__actions__ENABLED: "false"
volumes:
- ./data:/data:Z # capital Z relabels for SELinux (Fedora)
- /etc/localtime:/etc/localtime:ro # host clock; Fedora has no /etc/timezone to bind
ports:
- "127.0.0.1:3000:3000" # loopback only, the tunnel reaches it here
restart: unless-stopped
Every hardening line earns its place. Registration off means only accounts you create can exist. Sign-in required means an anonymous visitor sees a login page, not your repository list. SSH off means the only way in is HTTPS with a token, which suits a tunnel. Private by default means a repo is never public by a slip of the mouse. Bring it up from the compose directory:
podman-compose -p forgejo up -d
The first run creates ./data, which holds the SQLite database, your repositories and the generated config. INSTALL_LOCK skips the web installer, so the forge is configured entirely from the compose file with no setup wizard exposed on the network.
Step 2, the tunnel
Authorise cloudflared for your domain's zone, then create a tunnel and note the id it prints:
cloudflared tunnel login
cloudflared tunnel create forgejo
cloudflared tunnel list # copy the new tunnel-id
Write its config to ~/.cloudflared/forgejo.yml:
tunnel: <tunnel-id>
credentials-file: /home/user/.cloudflared/<tunnel-id>.json
ingress:
- hostname: git.example.com
service: http://localhost:3000 # must match the loopback port in compose.yml
- service: http_status:404
Then point the name at the tunnel. The simplest route with no certificate juggling: in the Cloudflare dashboard for your domain, add a proxied (orange-cloud) DNS record, a CNAME from git to <tunnel-id>.cfargotunnel.com. That is it. There is a cloudflared tunnel route dns command that does the same thing, use one or the other, never both.
Why a tunnel and not a forwarded port. The tunnel makes an outbound connection to Cloudflare and traffic rides back down it, so there is nothing to open on the router and your home IP never lands in public DNS. Same reason the sites use it: the box stays invisible, only the names are public.
Step 3, survive a reboot
Rootless containers do not come back after a reboot on their own. Two small user units fix it. One brings the compose stack up at boot, one runs the tunnel:
# ~/.config/systemd/user/forgejo.service
[Unit]
Description=Forgejo (podman-compose stack)
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=%h/forgejo
ExecStart=/usr/bin/podman-compose -p forgejo up -d
ExecStop=/usr/bin/podman-compose -p forgejo down
[Install]
WantedBy=default.target
# ~/.config/systemd/user/forgejo-tunnel.service
[Unit]
Description=cloudflared tunnel for Forgejo
After=network-online.target
Wants=network-online.target
[Service]
ExecStart=/usr/bin/cloudflared tunnel --config %h/.cloudflared/forgejo.yml run
Restart=on-failure
RestartSec=5
[Install]
WantedBy=default.target
Enable both, then turn on lingering so user services run without you logged in:
systemctl --user daemon-reload
systemctl --user enable forgejo.service
systemctl --user enable --now forgejo-tunnel.service
loginctl enable-linger "$USER"
Then do the reboot test, for real, not as an assumption. Reboot the machine and confirm both halves returned with no manual step: curl -I http://localhost:3000/ on the server (the container is back), then load https://git.example.com/ from off your LAN (the tunnel is back). Untested recovery is not recovery.
Step 4, your account and first repo
Create the admin account from the command line. The forgejo CLI refuses to run as root and podman exec defaults to root, so pass -u git:
podman exec -it -u git forgejo_server_1 forgejo admin user create \
--admin --username you --email [email protected] \
--random-password --must-change-password
It prints a one-time password. Log in at https://git.example.com, change it at once, turn on 2FA. Make a private repository in the UI, then push to it over HTTPS with a personal access token (Settings, Applications, generate a token, use it as the git password):
git remote add forge https://git.example.com/you/project.git
git push forge main # username = you, password = the token
That is a working private forge. Everything past here is about keeping it safe.
A fence so a secret can never land in a repo
Private is not the same as safe. Private still means written down, in history, forever. The one mistake a private forge does not save you from is committing a secret in the first place. A leaked key in a private repo is still a leaked key the day the repo is shared, mirrored or breached.
A server-side pre-receive hook closes that gap. It scans every push before the forge accepts it and rejects the whole thing if it sees an API key, a private address or a file that should never be tracked. Because it runs on the server it cannot be skipped, unlike a client-side hook that a --no-verify waves past:
#!/bin/sh
# pre-receive: refuse a push that adds a secret or a private address
block='(-----BEGIN [A-Z ]*PRIVATE KEY|api[_-]?key|\b10\.[0-9]+\.[0-9]+\.[0-9]+\b|\b192\.168\.[0-9]+\.[0-9]+\b|[0-9a-f-]{36}\.cfargotunnel)'
status=0
while read old new ref; do
if git diff "$old" "$new" | grep -nEi "$block"; then
echo "push rejected: that looks like a secret or a private address" >&2
status=1
fi
done
exit $status
Tune the patterns to what you must never publish: your own private IP ranges, tunnel ids, the filenames of your env and database. Install it as the repository's pre-receive hook (git hooks are admin-only in Forgejo, which is exactly why registration is off and the admin account is locked down with 2FA). The same scan is worth running before any public mirror step too, so nothing private slips into the copy that leaves the building.
Public when you want it
A private forge does not cut you off from open source. Keep the private repositories at home and push the public ones out to Codeberg with a one-way sync: a small script that mirrors named repositories, by hand or on a schedule. The forge is the source of truth, the public mirror is a shop window. Run the secret scan before the sync and the wall between private and public holds.
Backups
All state lives in ./data. Under rootless podman that data is owned by a container subuid, so host tar trips on the permissions. Back up through the container instead:
podman exec -u git forgejo_server_1 forgejo dump -f /data/dump.zip
podman cp forgejo_server_1:/data/dump.zip ~/backups/forgejo-$(date +%F).zip
podman exec forgejo_server_1 rm -f /data/dump.zip
forgejo dump bundles the database, repositories, config and avatars into one file. Do one restore drill onto a scratch instance before you trust it. A backup you have never restored is a guess.
Field notes
Quick start:
podman-compose upthe Forgejo container on loopback:3000.- A cloudflared tunnel plus a proxied CNAME point
git.example.comat it. No open ports. - Two user systemd units plus linger so it survives a reboot. Then actually reboot and check.
- Create your admin account (
-u git), turn on 2FA, make a private repo, push over HTTPS with a token. - Install a
pre-receivefence so a secret can never be committed.
Footguns:
- The loopback port and the tunnel's service port must match. A drift between them gives you a 502.
:Z(capital) on the data mount for SELinux. A lowercase:zhas caused total 404s.- Pin a major image tag, never
:latest. An unattended major bump can need a manualforgejo migrate. - SSH stays off. If a clone fails, hand out a personal access token, do not turn SSH on to fix it.
- The forgejo CLI refuses to run as root. Every admin command needs
-u gitunderpodman exec. - Rootless data is owned by a subuid. Back up and install hooks through the container, not with host
tar. - Sharing the box with other sites is fine, the forge does not touch them. One rule holds both directions: never restart a site's tunnel to fix the forge, never the forge's to fix a site.
- A tunnel plus login-required is the right way to expose a forge. That is the opposite of a local AI model, which should never face the public internet at all.