Maintaining Gitea with Let's Encrypt on macOS via Homebrew — and the push failure nobody warns you about

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.

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/

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’

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.

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).

QuestionHow 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 localhost and 127.0.0.1 in its SANs and the default LOCAL_ROOT_URL works 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 http internally (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 localhost now fail. Set LOCAL_ROOT_URL to 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.ini is live. Editing the wrong file is the most common self-inflicted wound.
  • Treat every brew upgrade as 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 to LOCAL_ROOT_URL explaining 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.