← Back to Wiki
Self-Hosting / Docker

Self-Host Your Own Git Server (Gitea)

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.

Share on X

Basic deployment

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"
SSH port 222, not 22. If Gitea is running as a container on a VM/LXC that also has its own sshd for management access, publishing Gitea's internal SSH port straight to host port 22 collides with — or silently shadows — the box's own SSH daemon. Publish it on a different host port instead ("222:22" above) and clone with an explicit port: git clone ssh://[email protected]:222/user/repo.git.

Skip the web install wizard entirely

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.

Putting it behind a reverse proxy

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.

Gotcha: a reverse-proxy config reload can silently no-op after a manual edit. Editing a Caddyfile by hand and running 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.

Mirroring an existing GitHub repo, without giving up GitHub

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.

Don't put the access token straight in the remote URL. A URL like 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"

If you plan to mirror in CI instead of from a workstation

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.

Backup coverage

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.