Gitea is a lightweight, single-binary Git server with a real web UI, issues, and GitHub-Actions-compatible CI — a much smaller footprint than GitLab CE for a homelab that mostly just wants private repos and somewhere to keep VM/infra configs. This isn't about replacing GitHub — it's a second place to put things that don't need (or shouldn't have) a public home, deployed headlessly with no web install wizard involved.
Docker Compose, Postgres backing the app rather than Gitea's default SQLite — worth the extra container for anything you intend to keep around, same reasoning as any other real self-hosted app:
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
- POSTGRES_USER=gitea
- POSTGRES_PASSWORD=<generated>
- POSTGRES_DB=gitea
volumes:
- ./postgres:/var/lib/postgresql/data
gitea:
image: gitea/gitea:latest
restart: unless-stopped
depends_on:
- postgres
environment:
- USER_UID=1000
- USER_GID=1000
- GITEA__database__DB_TYPE=postgres
- GITEA__database__HOST=postgres:5432
- GITEA__database__NAME=gitea
- GITEA__database__USER=gitea
- GITEA__database__PASSWD=<generated>
- GITEA__server__DOMAIN=git.example.com
- GITEA__server__ROOT_URL=https://git.example.com/
- GITEA__server__SSH_DOMAIN=git.example.com
- GITEA__server__SSH_PORT=222
- GITEA__service__DISABLE_REGISTRATION=true
- GITEA__security__INSTALL_LOCK=true
volumes:
- ./data:/data
ports:
- "3000:3000"
- "222:22"
"222:22" above) and clone with an explicit port:
git clone ssh://[email protected]:222/user/repo.git.
GITEA__security__INSTALL_LOCK=true tells Gitea its config is already final — on first boot it
runs the DB migration itself and skips the browser-based setup form. Create the actual admin account from the
CLI instead:
docker exec -u git <container> gitea admin user create \
--username admin --password '<generated>' \
--email [email protected] --admin --must-change-password=false
Combined with GITEA__service__DISABLE_REGISTRATION=true, this gives you a fully headless,
admin-created-accounts-only instance without ever touching a web form — the same posture as most self-hosted
password managers, and a reasonable default for anything that doesn't need self-serve signups.
Whether this needs a public domain or can stay internal-only depends entirely on what you're using it for. If it's just a private store for your own infra configs, there's a real case for keeping it LAN-only — no public DNS record, and a certificate from your own internal CA rather than a publicly trusted one. See the internal CA guide for the general pattern (mint a static cert directly via your CA's CLI, skip your reverse proxy's own ACME automation for that one host). If you do need to clone/push from outside your LAN, treat it like any other public self-hosted app — see the reverse proxy guide for the general Caddy setup.
caddy reload against the admin API can log
"config is unchanged" and skip applying the new block entirely — even though the file on disk is
definitely different and caddy validate confirms it parses cleanly. If a reverse-proxy edit
doesn't seem to take effect after a reload, don't keep retrying reload — restart the container instead. That
reliably picks up the on-disk config from scratch.
You don't have to choose one host. A single origin remote can push to two URLs at once — clone
normally against your existing GitHub remote, then add a second push target:
git remote set-url --add --push origin [email protected]:you/repo.git
git remote set-url --add --push origin https://git.example.com/you/repo.git
git push --dry-run # confirm it lists both targets
From then on, a plain git push fans out to both automatically — GitHub stays canonical (issues,
CI, releases, the public face of the project), Gitea becomes a second, independently-backed-up copy.
https://user:[email protected]/... works, but it puts the secret in plain sight in
git remote -v output, shell history, and anything else that echoes command text. Generate a
scoped token (repo-write is enough — don't reuse the account password) and put it in a dedicated git
credential-store file instead, referenced only for that one host:
# .git/gitea-credentials (chmod 600, lives inside .git/ so it's never tracked)
https://user:<token>@git.example.com
# scope the credential helper to just this host
git config --local credential.https://git.example.com.helper \
"store --file=.git/gitea-credentials"
Worth checking upfront: if your Gitea instance is internal/LAN-only (no public DNS, no public route in), a hosted CI runner (GitHub Actions' own hosted runners, for example) has no path to reach it. A CI-driven mirror step needs either a public route to your Gitea instance or a self-hosted runner sitting on the same network — it's not a config problem to debug, it's a genuine reachability gap. If neither of those exists yet, pushing from wherever your commits actually originate (a workstation, per the pattern above) covers everything in practice without needing either.
Same rule as any other stateful self-hosted app: whatever backs up your other VMs/containers needs this one added to its job explicitly — a fresh container isn't automatically covered by an existing backup schedule just because it's running on the same host pool as everything else.