{
"$type": "site.standard.document",
"bskyPostRef": {
"cid": "bafyreibpsl7qp6awty7zwzlwpjtc62oiri5aekfby652esen4wo3fxxfpy",
"uri": "at://did:plc:iw5jw32bqqdf4bkbhotvf23m/app.bsky.feed.post/3mnlnh6w732u2"
},
"description": "It started, as all good server adventures do, with a suspiciously high load average and that sinking feeling that something somewhere was chewing through CPU like it had been personally wronged.\n\nAt first glance, the usual suspects appeared: Nginx workers, database processes, Docker containers, and a few background services minding their own business. But one process kept standing out from the crowd: Forgejo, along with several Git-related commands. The server was not merely busy; it was being w",
"path": "/the-night-we-put-forgejo-behind-the-tailnet/",
"publishedAt": "2026-06-06T02:51:21.000Z",
"site": "https://pablomurad.com",
"textContent": "It started, as all good server adventures do, with a suspiciously high load average and that sinking feeling that something somewhere was chewing through CPU like it had been personally wronged.\n\nAt first glance, the usual suspects appeared: Nginx workers, database processes, Docker containers, and a few background services minding their own business. But one process kept standing out from the crowd: Forgejo, along with several Git-related commands. The server was not merely busy; it was being worked hard.\n\nThe first move was blunt but effective: bring the Forgejo stack down with Docker Compose. Almost immediately, the load began to fall. That was the smoking gun. Forgejo was not just involved; Forgejo was the center of gravity.\n\nFrom there, the investigation split into two questions:\n\n 1. Why was Forgejo consuming so much CPU?\n 2. How could it be locked down without making it useless?\n\n\n\nThe answer to the second question shaped the whole plan: Forgejo should not be public. There was only one real user, so leaving a Git service exposed to the entire internet made no sense. Bots, crawlers, scanners, and opportunistic traffic were all invited to knock on the door, and some of them were clearly doing more than knocking.\n\nThe goal became simple:\n\n> Keep Forgejo fully usable, but only inside the Tailscale network.\n\nFirst, the Docker Compose configuration was inspected. The web interface was already bound safely to localhost, but the SSH Git port was still exposed publicly. That was a problem. The SSH mapping was changed so it listened only on the server’s Tailscale IP. After recreating the containers, the public Git SSH port was gone, and SSH access was limited to the Tailnet.\n\nThen came the Nginx side of the story.\n\nAt first, the wrong Nginx site file was edited. The configuration looked right, but requests still returned `200 OK` from the public internet. That was the clue: Nginx was not using the file that had just been changed. A full configuration dump revealed the real active virtual host, generated under a different filename. Once the correct file was found, the fix was applied properly:\n\n\n allow 100.64.0.0/10;\n deny all;\n\nThat single rule changed everything. Public requests to the Forgejo domain began returning:\n\n\n HTTP/2 403\n\nPerfect. The internet was locked out.\n\nBut access through Tailscale still worked. A forced test resolving the Forgejo domain to the server’s Tailscale address returned:\n\n\n HTTP/2 200\n\nThat proved the architecture was correct:\n\n\n Public internet -> blocked\n Tailscale network -> allowed\n\nThe CPU confirmed it too. Forgejo dropped from hundreds of percent of CPU usage to almost nothing. The server began to breathe again.\n\nThe last challenge was DNS.\n\nEditing every client’s hosts file would have worked, but it was ugly. Nobody wants to maintain a pile of manual host overrides across several machines. The better solution was split DNS through Tailscale.\n\nA small `dnsmasq` service was installed on the server and configured to listen only on the Tailscale interface. It answered one important question:\n\n\n Forgejo domain -> server Tailscale IP\n\nTesting directly against that DNS server worked. Then the Tailscale admin console was configured to use the server as a custom nameserver for the Forgejo domain. After refreshing the Tailscale clients, machines inside the Tailnet could resolve the Forgejo domain internally, without touching local hosts files.\n\nAt the end of the adventure, the setup looked like this:\n\n\n Forgejo web:\n available only through Tailscale\n\n Forgejo SSH:\n bound only to the server’s Tailscale IP\n\n Public domain:\n blocked by Nginx unless the client comes from the Tailnet\n\n Internal DNS:\n resolves the Forgejo domain to the Tailscale IP\n\n Git clone, pull, and push:\n work normally from Tailnet devices\n\n Random internet bots:\n get a 403 and go bother someone else\n\nThe important lesson was simple: exposing a private Git server to the public internet is usually unnecessary risk. Even if registration is disabled and there is only one user, bots can still hammer expensive Git endpoints, trigger pack generation, request archives, crawl diffs, and generally waste CPU.\n\nThe clean solution was not to harden the public door forever.\n\nIt was to remove the public door.\n\nForgejo stayed fully functional, but became private to the Tailnet. The server load dropped, the attack surface shrank, and Git access became exactly what it should have been from the start: available to trusted machines only.",
"title": "The Night We Put Forgejo Behind the Tailnet",
"updatedAt": "2026-06-06T02:51:21.624Z"
}