Gitea is a lightweight, self-hosted Git service that runs happily on a spare Mac. Installing it through Homebrew with Let’s Encrypt HTTPS takes an afternoon — and then, weeks later, a routine brew upgrade breaks every git push with a baffling “Internal Server Connection Error” while the web UI carries on as if nothing is wrong. This is the full setup, plus the one failure that cost me an evening and the one config line that fixes it for good.
TL;DR – The short version. this is a single line fix in app.ini. The rest of this article is how to get there in the first place.
Contents
- Why this combination
- Install and lay out the directories
- Configure app.ini for HTTPS and ACME
- Run it as a background service
- Let ACME reach the box (port 443)
- When the web UI works but pushes fail
- The fix
- Why it appeared out of nowhere: the Homebrew upgrade
- Diagnosing it yourself, without the guesswork
- Alternatives, if you’d rather not touch hosts
- Lessons
The short version. If your pushes fail in the pre-receive hook but the web UI and git clone work, jump to “When the web UI works but pushes fail”. The fix is a single LOCAL_ROOT_URL line in app.ini.
Why this combination
A Mac mini or an old laptop makes a perfectly good always-on Git server for a small team or a solo shop. Homebrew gives you the binary and a launch service; Gitea’s built-in ACME gets you a real Let’s Encrypt certificate with no reverse proxy, no certbot cron, no renewal to remember. The catch is that “built-in ACME” changes one assumption deep in Gitea’s plumbing — and that assumption is exactly what bites later. We’ll build it the normal way first, then meet the thing that bites.
Install and lay out the directories
Install with Homebrew and let it create its working tree under /opt/homebrew/var/gitea:
brew install gitea
# Homebrew’s layout on Apple Silicon:
# binary /opt/homebrew/opt/gitea/bin/gitea
# workdir /opt/homebrew/var/gitea (repos, data, custom config, logs)
# config /opt/homebrew/var/gitea/custom/conf/app.ini
# logs /opt/homebrew/var/log/gitea.log
The key thing to internalise now, because it matters at debugging time: the running process is pointed at a work path, and it derives the config file from that — <work-path>/custom/conf/app.ini. There is no explicit --config flag in the default Homebrew service. Miss that and you will spend an hour editing an app.ini the server never reads.
Configure app.ini for HTTPS and ACME
Edit /opt/homebrew/var/gitea/custom/conf/app.ini. The [server] section is where HTTPS, your domain and ACME live — and where the line that saves you later belongs:
[server]
PROTOCOL = https
DOMAIN = git.example.com
HTTP_PORT = 3000
ROOT_URL = https://git.example.com:3000/
; Built-in Let’s Encrypt. Gitea obtains and renews the cert itself.
ENABLE_ACME = true
ACME_ACCEPTTOS = true
ACME_DIRECTORY = https
ACME_EMAIL = This email address is being protected from spambots. You need JavaScript enabled to view it.
; --- The line everyone forgets. See “When pushes fail” below. ---
; Server-side Git hooks call BACK into Gitea’s own API. Without this they
; default to https://localhost:3000/ — for which an ACME cert never exists.
LOCAL_ROOT_URL = https://git.example.com:3000/
LOCAL_ROOT_URL in from day one. It costs nothing when everything works, and it is the difference between a five-second fix and a two-hour hunt the day something resets your config. If you take nothing else from this article, add that line.Run it as a background service
Homebrew ships a launch agent that keeps Gitea alive and running at login. You can use brew services directly, or drop in your own plist if you want to pin the work path explicitly. The important parts are the --work-path argument and sending output to the log Gitea also writes to:
<?xml version="1.0" encoding="UTF-8"?>
<plist version="1.0">
<dict>
<key>Label</key> <string>homebrew.mxcl.gitea</string>
<key>KeepAlive</key> <true/>
<key>RunAtLoad</key> <true/>
<key>ProgramArguments</key>
<array>
<string>/opt/homebrew/opt/gitea/bin/gitea</string>
<string>web</string>
<string>--work-path</string>
<string>/opt/homebrew/var/gitea</string>
</array>
<key>StandardOutPath</key> <string>/opt/homebrew/var/log/gitea.log</string>
<key>StandardErrorPath</key> <string>/opt/homebrew/var/log/gitea.log</string>
</dict>
</plist>
# Start / restart / check
brew services restart gitea
brew services list | grep -i gitea
# Validate a hand-edited plist before loading it
plutil -lint ~/Library/LaunchAgents/homebrew.mxcl.gitea.plist
Let ACME reach the box (port 443)
Let’s Encrypt has to connect in to prove you control the domain, and Gitea’s built-in ACME answers the challenge on its own TLS listener. Since we’re serving on 3000, the tidy trick on macOS is a packet-filter redirect so the outside world hits 443 and the kernel forwards it to 3000 — no need to run Gitea as root to bind a low port:
# /etc/pf.anchors/gitea
rdr pass on en0 inet proto tcp from any to any port 443 -> 127.0.0.1 port 3000
# Load it (and make your DNS A record + router forward 443/80 to this Mac).
sudo pfctl -ef /etc/pf.conf
Point git.example.com at the box, forward 443 (and 80 if you use the HTTP-01 challenge) from your router, restart Gitea, and watch the log — you’ll see it fetch the certificate on first boot:
tail -f /opt/homebrew/var/log/gitea.log
At this point the web UI loads over HTTPS with a green padlock, you can create repos, and git clone works. Job done — until the next upgrade.
When the web UI works but pushes fail
Here’s the failure that sent me down a rabbit hole. Everything looked healthy — padlock green, clones fine — but every push bounced:
$ git push origin main
remote:
remote: error:
remote: error: Internal Server Connection Error
remote: error:
To https://git.example.com:3000/example/repo.git
! [remote rejected] main -> main (pre-receive hook declined)
error: failed to push some refs
pre-receive hook declined is the tell. A pre-receive hook is a script that runs on the server and can veto a push. Gitea’s hooks don’t do their work in-process — they call back into Gitea’s own internal API over HTTP to validate the push. If that callback fails, the hook fails, and the push is refused — no matter what’s in your commit. So this is never a problem with your code, and clones (which don’t run the hook) keep working, which is what makes it so confusing.
The server log named the real culprit in one line:
http: TLS handshake error from [::1]:49286: no certificate available for ‘localhost’
LOCAL_ROOT_URL, which defaults to https://localhost:3000/. But with ACME, Gitea only holds a certificate for git.example.com — it has no certificate for localhost. So the hook opens a TLS connection to loopback, presents the SNI name localhost, Gitea can’t find a matching cert, and drops the handshake. Hook can’t reach the API → “Internal Server Connection Error” → push declined. Note the [::1]: the callback resolves localhost to IPv6 loopback.Plain self-signed Gitea never hits this, because it serves one static cert for any SNI. ACME is per-domain by design, so there simply is no localhost cert to fall back on. The moment you turned on Let’s Encrypt, you signed up for this — you just didn’t find out until a push.
The fix
Make the hook call a hostname your certificate actually covers, and keep that call on loopback so it never leaves the machine. Two changes.
1. Set LOCAL_ROOT_URL to your real domain in [server]:
LOCAL_ROOT_URL = https://git.example.com:3000/
2. Resolve that domain to loopback on the box itself so the callback stays local and still presents matching SNI. Add both address families — remember the handshake came from [::1]:
# /etc/hosts (on the Gitea machine only)
127.0.0.1 git.example.com
::1 git.example.com
Then restart and push:
brew services restart gitea
Now the hook connects to git.example.com, /etc/hosts pins it to loopback, and the TLS handshake presents SNI git.example.com — which your ACME cert covers. The handshake succeeds, the callback returns, the hook passes, and the push lands.
Why it appeared out of nowhere: the Homebrew upgrade
The infuriating part was that this had been working for months. What changed was a brew upgrade — it bumped Gitea across a version and, in doing so, rewrote my app.ini and dropped my explicit LOCAL_ROOT_URL line. With the line gone, Gitea fell back to the localhost default and the ACME mismatch surfaced on the very next push. An upgrade is the classic trigger precisely because it resets config you didn’t know you depended on.
LOCAL_ROOT_URL survived, and (2) regenerate the per-repo hook scripts, which can also be left stale by a binary path change:
gitea --work-path /opt/homebrew/var/gitea admin regenerate hooks
Diagnosing it yourself, without the guesswork
If you land here without knowing the cause, this is the fastest path from symptom to fix. It also saved me from chasing the wrong things (it is not a database problem — the default Homebrew Gitea uses SQLite, a file, so there is no DB connection to fail).
| Question | How to answer it |
|---|---|
Which app.ini is actually loaded? |
Admin → Site Administration → Configuration shows the Configuration File Path outright. Don’t trust the path you think you’re editing. |
| What is the process really running with? | ps -ax -o pid,command | grep '[g]itea' — look for --work-path / --config. The config is derived from the work path if there’s no explicit --config. |
Did my LOCAL_ROOT_URL edit land in [server]? |
awk '/^\[/{s=$0} /LOCAL_ROOT_URL/{print s" -> "$0}' <app.ini> — must print [server] -> …. Empty output means the key isn’t there at all. |
| Does the domain resolve to loopback here? | dscacheutil -q host -a name git.example.com on macOS. If it returns the WAN/LAN IP, your /etc/hosts pin isn’t taking and the callback is dialing out instead of staying local. |
| What is the hook actually failing on? | tail -f /opt/homebrew/var/log/gitea.log during a push. The failure is logged under the POST …/git-receive-pack line — the TLS handshake…localhost line is the smoking gun. |
Alternatives, if you’d rather not touch hosts
- Add SANs for loopback. If you control the certificate, include
localhostand127.0.0.1in its SANs and the defaultLOCAL_ROOT_URLworks untouched. With Gitea’s built-in ACME you don’t, so this is really only for manually-managed certs. - Terminate TLS at a reverse proxy. Run Gitea on plain
httpinternally (PROTOCOL = http) behind Caddy/nginx doing HTTPS. The hook’s callback is then plain HTTP to loopback and the whole SNI problem evaporates — at the cost of another moving part.
For a single-box Homebrew setup, the LOCAL_ROOT_URL + /etc/hosts pair is the least amount of work for the fix, and it keeps ACME doing the certificate work you turned it on for.
Lessons
- ACME quietly removes the “any SNI” safety net. A per-domain cert means loopback calls that assume
localhostnow fail. SetLOCAL_ROOT_URLto a covered name the moment you enable Let’s Encrypt. - “Web works, push fails” points at the hook, not your commit. Server-side hooks call back into the API; a broken callback declines every push while clones sail through.
- Read the config path from the horse’s mouth. The admin Configuration page tells you which
app.iniis live. Editing the wrong file is the most common self-inflicted wound. - Treat every
brew upgradeas a config reset. Re-verify the settings you depend on and regenerate hooks. What broke “for no reason” almost always broke on an upgrade. - Pin the fix in
app.ini, not in your memory. A comment next toLOCAL_ROOT_URLexplaining why it’s there is the cheapest insurance against re-solving this in six months.
None of this is Gitea’s fault, exactly. It is more that this is a seam between “a service that calls itself over HTTPS” and “certificates that are, correctly, specific to a name.” Once you see the seam, the fix is one line and the diagnosis is one log entry. The trap is only a trap the first time. This is not an issue with Homebrew either. It is marvellous and more secure for your Mac that Homebrew can make these services available without requiring anything other than user permissions.