VPS part 3: Self-hosted git with Gogs
Part 3 of 5 in a series about building a small self-hosted web server with an AI agent. Part 2 covers the deploy scripts.
Code needs somewhere to live and be reviewed. Instead of a hosted service,
the server runs its own git host: Gogs, at
git.monoexp.com. It's a single Go binary with a web UI for repositories,
issues and pull requests, and it's light enough to share a 4 GB machine with
everything else.
Setup
Installing Gogs, its database and its configuration: manual setup by admin. From the outside, it follows the same pattern as everything else on the server:
┌──────────── server ────────────┐
browser ── HTTPS :443 ─▶│ nginx ─▶ 127.0.0.1:3000 Gogs │
│ │
git ───── SSH :22 ─────▶│ sshd ─▶ git user ─▶ Gogs │
└────────────────────────────────┘
-
Web UI over HTTPS. nginx terminates TLS and proxies to Gogs on
127.0.0.1:3000. Gogs isn't reachable from outside except through nginx. -
git over SSH on port 22. Clones and pushes go to
git@git.monoexp.comthrough the system's SSH server. Gogs matches the key to an account and checks repository permissions. Logging in for a shell isn't possible:$ ssh -T git@git.monoexp.com Hi there, You've successfully authenticated, but Gogs does not provide shell access. -
Its own service account. Gogs runs as the
gituser from a plain systemd unit:[Unit] Description=Gogs After=network.target [Service] User=git Group=git WorkingDirectory=/home/git/gogs ExecStart=/home/git/gogs/gogs web Environment=USER=git HOME=/home/git Restart=always [Install] WantedBy=multi-user.target
The nginx file is the same shape as every other site, with one addition:
server {
listen 443 ssl; listen [::]:443 ssl;
server_name git.monoexp.com;
ssl_certificate /etc/letsencrypt/live/git.monoexp.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/git.monoexp.com/privkey.pem;
include snippets/tls-common.conf;
client_max_body_size 512m;
location / {
proxy_pass http://127.0.0.1:3000;
include snippets/proxy.conf;
}
}
client_max_body_size 512m raises nginx's default 1 MB upload limit, which
would otherwise break large pushes over HTTPS and file uploads in the web
UI.
How the agent gets access
The agent has a Gogs account with write access to the blog's repository,
bajdev/posts, and an SSH key on
the server. That's all it has: no password for the web UI and no API token.
SSH is enough to clone, push branches, delete branches and push tags, which
covers everything the workflow needs.
The missing piece: no pull request API
Gogs has pull requests in its web UI, but its API doesn't include them. Gitea, which forked from Gogs, added them later. So an agent that only has git access can't open a PR itself.
The workaround uses something Gogs does have: a compare page for any two branches.
agent admin
───── ─────
push feature/x ───────────────────▶ open compare link:
git.monoexp.com/bajdev/posts/
compare/main...feature/x
click "New Pull Request"
review, then merge
told "merged" ◀────────────────────
check, delete branch, tag, deploy
The agent pushes a branch and sends the compare link. The admin opens the PR from it with one click, reviews it, and merges. Part 5 shows how this grew into a full release process, including where the PR description comes from when the agent can't write it into Gogs.
The first push
A new, empty repository has a chicken-and-egg problem: there's no main
branch, so there's nothing for a pull request to target. The rule is
"never push to main", but that first push was allowed once, explicitly,
for a README describing the app. Every change since then has gone through
a branch and a reviewed merge.
After that, the rule stopped being just a rule. main is a protected
branch in Gogs (the repository's Settings → Branches), so the server
itself refuses direct pushes to it. Changes reach main only through a
merged pull request, no matter who or what is pushing.
Part 4 is the first real app: this blog.