Gitea is a lightweight, single-binary Git server with a real web UI, issues, and GitHub-Actions-compatible CI. Much smaller footprint than GitLab CE for a homelab that mostly wants private repos and somewhere to keep VM and infra configs. This is not about replacing GitHub. It is a second place to put things that do not need a public home, or should not have one. Deployed headlessly, with no web install wizard involved.
Docker Compose, with Postgres backing the app instead of Gitea's default SQLite. Worth the extra container for anything you intend to keep. 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 setup form. Create the 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
Combine that with GITEA__service__DISABLE_REGISTRATION=true and you get a fully headless
instance, admin-created accounts only, without ever touching a web form. It is the same posture as most
self-hosted password managers, and a reasonable default for anything that does not need self-serve
signups.
Whether this needs a public domain or can stay internal-only depends on what you use it for. If it is just a private store for your own infra configs, there is 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. The internal CA guide has the pattern. Mint a static cert directly via your CA's CLI, and skip your reverse proxy's own ACME automation for that one host. If you do need to clone and push from outside your LAN, treat it like any other public self-hosted app. The reverse proxy guide has the Caddy setup.
caddy reload against the admin API, and it can log
"config is unchanged" and skip the new block entirely. The file on disk is definitely different,
and caddy validate confirms it parses cleanly. If a reverse-proxy edit does not take effect after
a reload, do not keep retrying the reload. Restart the container. That picks up the on-disk config from
scratch.
You do not 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. GitHub stays canonical, with issues, CI,
releases and 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.
git remote -v output, shell history, anything else that echoes command text. Generate a scoped
token instead. Repo-write is enough, and do not reuse the account password. Put it in a dedicated git
credential-store file, 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"
Check this upfront. If your Gitea instance is internal or LAN-only, with no public DNS and no public route in, a hosted CI runner has no path to reach it. GitHub Actions' own hosted runners, for example. A CI-driven mirror step needs either a public route to your Gitea instance or a self-hosted runner on the same network. It is not a config problem to debug. It is a genuine reachability gap. If neither exists yet, push from wherever your commits originate. A workstation, per the pattern above. That covers every push you actually make.
Same rule as any other stateful self-hosted app. Whatever backs up your other VMs and containers needs this one added to its job explicitly. A fresh container is not automatically covered by an existing backup schedule just because it runs on the same host pool as everything else.