Self-Hosting Git with Gitea + Tailscale + Cloudflare Tunnel

(wip)

I’ve kinda always wanted to keep my own self-hosted backup of my GitHub, so I finally went ahead and tried it on my mac mini. I used Gitea for the Git server, GitHub pull mirrors for existing repos, Tailscale for private SSH access, Cloudflare Tunnel for the public web UI, and a few daily jobs for backup and cleanup.

I am writing this as I try the setup myself. So this is not meant to be a perfectly detached checklist copied from docs. It is more like me writing down what I did, where I changed my mind, and what I would probably keep if I had to set it up again.

At first, I thought this might be about replacing GitHub. It is not, at least not for me. GitHub is still good (or at least is still the industry standard) at public repo hosting, pull requests, comments, issues, stars, releases, and general discovery. What I wanted was another place where my repos live. A machine I control, reachable privately, visible publicly for read-only browsing, backed up daily, and restorable if something goes wrong.

The version I ended up with looks roughly like this.

GitHub
  source of truth for GitHub-first repos
  pull requests, issues, stars, comments

Gitea on the always-on machine
  local Git server
  native GitHub pull mirrors
  private repos
  public web UI
  daily backups

Tailscale
  private SSH Git access

Cloudflare Tunnel
  public HTTPS access to the Gitea web UI

launchd
  keeps Gitea, cloudflared, backups, metadata refresh, and cleanup running

I am using placeholders throughout the post.

I am intentionally not putting real local usernames, private IPs, tunnel IDs, email addresses, token paths, or secrets in a public post.


1. What I Ended Up Building

I ended up thinking about the setup as two kinds of repos.

The first kind is a GitHub-first repo.

laptop -> push to GitHub -> Gitea pulls from GitHub

For repos like this, I let GitHub stay the source of truth. Gitea is a native pull mirror. It keeps a local copy of branches and tags, and it shows the mirror icon in the UI.

The second kind is a Gitea-first repo.

laptop -> push to Gitea
optional later step -> publish to GitHub

For repos like this, I start on the local Git server. They stay local unless I explicitly decide to publish them to GitHub.

This split mattered more than I expected.

When a repo already lives on GitHub and people interact with it there, I prefer to let GitHub stay in charge. Pull requests, comments, issues, and stars are not Git objects. Mirroring commits alone does not move that whole social layer.

When a repo starts locally, Gitea can be the source of truth. I can still publish it to GitHub later if that becomes useful.

I kept this shape because it lets GitHub do what GitHub is good at, while still giving me a local Git home that I can control and back up.


2. Why Gitea

I picked Gitea because it is small and boring in a good way.

These were the pieces I needed.

This was the mental model I liked.

launchd -> gitea binary -> SQLite + bare Git repos

There is no container layer in my version. There is nothing wrong with Docker, but I chose the direct binary install because it is easy to understand. If something fails, I only have a few places I usually check.

That felt like a manageable list.


3. Directory Layout

I kept the stack under one directory.

~/gitea-stack/
  bin/
  custom/conf/
  data/
  repositories/
  log/
  run/
  scripts/
  secrets/
  metadata/
  lists/

Each directory had a clear job for me.

This layout made backup simpler. Most state lives in one directory. Logs and temporary files can be excluded. I add anything outside the stack, such as SSH key material used by the service, to the backup explicitly.

I created the base directories with this command.

mkdir -p ~/gitea-stack/{bin,custom/conf,data,repositories,log,run,scripts,secrets,metadata,lists}
chmod 700 ~/gitea-stack/secrets

4. Installing Gitea

I installed Gitea from the official binary.

On Apple Silicon, I used the darwin-arm64 build. On Intel Macs, the equivalent would be darwin-amd64.

cd ~/gitea-stack/bin

curl -L -o gitea "https://dl.gitea.com/gitea/<version>/gitea-<version>-darwin-arm64"
curl -L -o gitea.asc "https://dl.gitea.com/gitea/<version>/gitea-<version>-darwin-arm64.asc"
chmod +x gitea

I verified the signature.

gpg --keyserver hkps://keys.openpgp.org --recv 7C9E68152594688862D62AF62D9AE806EC1592E2
gpg --verify gitea.asc gitea

I also checked that Git existed.

git --version

Gitea shells out to Git, so the machine needs Git installed too.


5. First Run And Web Install

I ran Gitea manually first.

GITEA_WORK_DIR=~/gitea-stack \
  ~/gitea-stack/bin/gitea web \
  --config ~/gitea-stack/custom/conf/app.ini \
  --work-path ~/gitea-stack

At this point I opened the installer only on a private address. I did not want the first install screen exposed publicly.

These were the basic settings I used.

After the first install, I locked down the config.

chmod 600 ~/gitea-stack/custom/conf/app.ini

Then I restarted Gitea and checked the health endpoint.

curl http://<private-address>:3000/api/healthz

At this stage, I still kept the server private.


6. Moving Secrets Out Of app.ini

Gitea creates several secret values during setup. I did not want those values pasted directly into app.ini, so I moved them into files under secrets/ and pointed app.ini to those files.

The config supports file URIs.

SECRET_KEY_URI = file:/path/to/secrets/secret-key
INTERNAL_TOKEN_URI = file:/path/to/secrets/internal-token
JWT_SECRET_URI = file:/path/to/secrets/oauth2-jwt-secret
LFS_JWT_SECRET_URI = file:/path/to/secrets/lfs-jwt-secret

The real paths are absolute local paths on the machine. I am not putting those exact paths in a public post.

I locked the files down.

chmod 700 ~/gitea-stack/secrets
chmod 600 ~/gitea-stack/secrets/*

I wanted app.ini to describe where secrets live, not become the place where every secret value is pasted forever.


7. Running Gitea With launchd

On macOS, I used launchd to run Gitea after reboot.

The service runs this command.

~/gitea-stack/bin/gitea web \
  --config ~/gitea-stack/custom/conf/app.ini \
  --work-path ~/gitea-stack

The LaunchDaemon has these settings.

After installing the plist, I checked it.

sudo launchctl print system/<service-name>

Then I checked Gitea.

curl http://<private-address>:3000/api/healthz

I also rebooted the machine and checked again. A server that does not survive reboot does not feel finished to me.


8. Private SSH Access With Tailscale

I used Tailscale before doing anything public.

This was the first networking choice I made because it gave me a private path before anything was on the public internet.

This was the private path.

laptop -> Tailscale -> Mac mini -> Gitea

This let another laptop clone and push without SSH-ing into the Mac mini first.

On the laptop, I created a dedicated SSH key.

ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519_gitea -C "gitea"

Then I added the public key to Gitea.

cat ~/.ssh/id_ed25519_gitea.pub

My SSH config entry looks like this.

Host gitea
  HostName <tailscale-ip>
  Port 2222
  User git
  IdentityFile ~/.ssh/id_ed25519_gitea
  IdentitiesOnly yes

I tested it with this command.

ssh -T gitea

Then I set a repo remote.

git remote set-url origin gitea:<gitea-user>/<repo>.git

At this point, private Git over SSH worked through Tailscale.


9. Choosing The Mirror Direction

There are two possible mirror directions.

Gitea -> GitHub
GitHub -> Gitea

For GitHub-first repos, I chose GitHub ⇒ Gitea.

GitHub is more than a Git remote. It also holds pull requests, comments, issues, stars, releases, and public repo pages. When the repo’s public activity already happens on GitHub, I think GitHub is the better source of truth.

So Gitea pulls from GitHub.

push to GitHub -> Gitea pulls from GitHub

I liked this direction because it gave me the things I cared about.

For Gitea-first repos, Gitea ⇒ GitHub can still make sense. The important part for me is that publishing to GitHub stays explicit.

I started with the idea of pushing from Gitea to GitHub for everything. I moved away from that because it made GitHub’s pull request and issue layer feel secondary, even though most public repo activity already happens there.


10. Creating Native Pull Mirrors

For an existing GitHub repo, I created a native Gitea pull mirror.

GitHub repo -> Gitea migration -> native pull mirror

For public repos, the GitHub URL was enough.

For private repos, Gitea needs credentials that can read the repo. I used a fine-grained GitHub token with read-only contents access. I did not give it write access.

I stored the token in a protected file.

~/gitea-stack/secrets/github-pull-mirror-token

Then I locked it down.

chmod 600 ~/gitea-stack/secrets/github-pull-mirror-token

I did not put this token in .zshrc, dotfiles, shell exports, or public scripts.

For automatic imports, I wanted this behavior.

I set the mirror interval to something short.

15 minutes

A webhook would make syncing more instant, but it also adds another public integration to secure. A short pull interval felt good enough.


11. Handling Gitea-First Repos

For a new repo that starts on Gitea, this is my flow.

git init repo-name
cd repo-name
git remote add origin gitea:<gitea-user>/repo-name.git
git push -u origin main

I set push-created repos to default to private.

When I want the repo to appear on GitHub later, I publish it deliberately.

gitea-push-to-github --private repo-name

or

gitea-push-to-github --public repo-name

A Gitea-to-GitHub push hook fits this case. The repo started on Gitea, so pushing it to GitHub is an intentional step instead of the default behavior for everything.


12. GitHub Metadata

Git stores commits, branches, tags, and files.

It does not store GitHub descriptions, topics, stars, homepage URLs, archived status, or visibility in a way Gitea automatically reuses.

So I kept a metadata refresh step.

GitHub API -> metadata JSON -> selected fields applied to Gitea

These are the fields I cared about.

I did not fake GitHub stars as Gitea stars. I stored the GitHub star count as metadata instead.

I run this daily, but only write to Gitea when something changed. Otherwise every repo starts looking recently updated even when no code changed.


13. Repository Updated Time

Gitea’s repository list has an Updated ... ago value.

For a mirror-heavy setup, this can become noisy. Mirror syncs and metadata refreshes can bump the repository timestamp even if the latest commit did not change.

I wanted the repo list to mean this.

repo list updated time = newest branch commit time

So I added a small normalizer.

every 60 seconds:
  for each repo:
    find newest branch commit time
    set repository updated time to that

I left the native mirror sync timestamp alone. Gitea can still show when the mirror last synced on the repo page.

So there are two separate signals.


14. Daily Jobs

The daily automation ended up as this schedule.

every 60 seconds:
  normalize repository updated times

03:05:
  discover new non-fork GitHub repos and import missing ones

03:20:
  refresh GitHub metadata

03:45:
  create backup and run maintenance

The discovery job does not sync existing repos. Native mirrors already do that on their own interval.

The discovery job only answers one question.

is there a new non-fork GitHub repo missing from Gitea?

When there is one, the job imports it as a native pull mirror.

I care about this because backups only include what exists in Gitea. If a new GitHub repo never gets imported, the Gitea backup cannot include it.


15. Backups

My backup includes these files.

It excludes these files.

The backup job does a few things.

I also run a restore check. Creating archives is not enough.

The restore check does this.

That gave me more confidence than just seeing archive files pile up.


16. Maintenance

The large files I found were not scripts.

They were old temp clones, migration bundles, and logs.

My maintenance job does this.

It keeps the small server small.


17. Public Access With Cloudflare Tunnel

Tailscale handles private access. Cloudflare Tunnel handles public web access.

I added Cloudflare only after the private Tailscale setup worked. That made the steps easier to debug. First I made Git work privately, then I made the web UI public.

I was partly inspired by this blog post, which explains the model nicely.

The high-level path became this.

browser -> Cloudflare -> cloudflared on the Git server -> local Gitea

The Git server does not need an inbound router port. cloudflared opens an outbound connection to Cloudflare, and Cloudflare routes the public hostname through that connection.

This was the basic flow I used.

cloudflared tunnel login
cloudflared tunnel create gitea
cloudflared tunnel route dns gitea git.example.com
cloudflared tunnel run gitea

My config looked like this.

tunnel: <tunnel-uuid>
credentials-file: ~/.cloudflared/<tunnel-uuid>.json

ingress:
  - hostname: git.example.com
    service: http://127.0.0.1:3000
  - service: http_status:404

For my actual setup, I stored the credentials file in a protected secrets directory and ran cloudflared with launchd.


18. Public Web, Private SSH

I kept web and SSH separate.

public web
  https://git.example.com
  Cloudflare Tunnel

private Git SSH
  gitea:<gitea-user>/<repo>.git
  Tailscale

The public internet can view public repos.

Git pushes still go through Tailscale.

This leaves the Gitea SSH server private. I do not have a reason to expose it publicly right now.

Gitea still enforces permissions.

Cloudflare gets traffic to the Git server. Gitea decides what each visitor can see.


19. Security Checklist

Before I called the setup done, I checked these things.

Self-hosting does not help much if tokens leak into shell files, public notes, logs, or old backup archives.


Gitea repo paths look like this.

https://git.example.com/<gitea-user>/<repo>

The default path is fine, but I wanted the public root page to feel more like a profile page. Gitea can show a profile README through a .profile repo.

For short repo links, I added a small Cloudflare Worker.

https://git.example.com/repo-name

It redirects to this.

https://git.example.com/<gitea-user>/repo-name

I made sure the Worker does not redirect real Gitea paths.


21. Normal Workflow

For a GitHub-first repo, I push to GitHub.

git push origin main

Gitea pulls the change on the mirror interval.

For a new GitHub repo, I let the import job bring it into Gitea.

gitea-import-new-github-repos --dry-run
gitea-import-new-github-repos

For a Gitea-first repo, I push to Gitea.

git remote add origin gitea:<gitea-user>/repo-name.git
git push -u origin main

To publish a Gitea-first repo to GitHub, I run this command.

gitea-push-to-github --private repo-name

For checks, I use these commands.

gitea-audit
gitea-security-check
gitea-restore-check

For a manual backup, I use this command.

gitea-backup

The setup has landed here for me. GitHub stays for now for public collaboration (@Cursor yall completing your GitHub killer anytime soon?), Gitea for a local mirror and Git home, Tailscale for private SSH, Cloudflare Tunnel for public web, and daily jobs for the boring parts.