Forgejo
A private git host in one container, behind Caddy for the web and on its own port for git over SSH.
Names and addresses are placeholders. The list says what each one stands for.
What it does here
Forgejo holds the repositories and runs Actions. The rest of the box exists to keep it running, reachable and backed up.
Canonical setup
One container from Forgejo's own registry, pinned to the current stable major, with its data in a volume. Finish the first-run screen in a browser: keep SQLite for a personal instance, create the admin account there, and turn off open registration.
Upstream: Installation with Docker.
In this build
- HTTP bound to 127.0.0.1:3000Only Caddy can reach it, as
git.dev.tailnet.example. - ROOT_URL on the private nameClone links and redirects use
https://git.dev.tailnet.example/. A later article moves this box to a public name for shared projects. - git over SSH on host port 2222, tailnet onlyThe host's own sshd keeps port 22 for administration. The port is bound to the tailnet address, because Docker's published ports bypass UFW.
- Data on /srv/data/forgejo
- Mail through the shared relayMailer settings are on the msmtp card.
services:
forgejo:
image: codeberg.org/forgejo/forgejo:15
container_name: forgejo
restart: unless-stopped
environment:
- USER_UID=1000
- USER_GID=1000
- FORGEJO__server__DOMAIN=git.dev.tailnet.example
- FORGEJO__server__ROOT_URL=https://git.dev.tailnet.example/
- FORGEJO__server__SSH_PORT=2222
volumes:
- /srv/data/forgejo:/data
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
networks: [forgejo]
ports:
- "127.0.0.1:3000:3000"
- "100.x.y.z:2222:22" # tailnet only
networks:
forgejo:echo 'net.ipv4.ip_nonlocal_bind = 1' | sudo tee /etc/sysctl.d/60-nonlocal-bind.conf
sudo sysctl --systemmkdir -p /srv/stacks/forgejo && cd /srv/stacks/forgejo
docker compose config
docker compose up -dBefore Caddy is up, reach the first-run screen through an SSH tunnel: ssh -L 3000:localhost:3000 youruser@devbox, then open http://localhost:3000.
Host git.dev.tailnet.example
Port 2222
User git
IdentityFile ~/.ssh/id_ed25519Seams
Where this meets the rest of the build. Each one leads with what you will actually see.
SymptomPermission denied
Port 22 is the host's sshd, which has no git user, so the short git@host:owner/repo form fails even with a valid key. Use the ssh:// form with the port, ssh://git@git.dev.tailnet.example:2222/OWNER/REPO.git, or the host entry above so the short form uses 2222.
Symptomlisten tcp4 100.x.y.z:2222: bind: cannot assign requested address
If Docker starts before Tailscale has brought up 100.x.y.z, a port bound to that address can't be created and Forgejo stays down after a reboot. net.ipv4.ip_nonlocal_bind = 1 lets the bind succeed before the address exists. Without the tailnet bind, "2222:22" answers on the home network, because Docker bypasses UFW; see the UFW card.
Symptomdid not find expected key
nano's autoindent mangles pasted YAML. Write compose files from the shell with a heredoc (cat > docker-compose.yml <<'EOF'), use spaces not tabs, and run docker compose config before up.
Verify
docker compose ps # forgejo is Up
curl -sI http://127.0.0.1:3000 | head -1 # HTTP 200
ssh -T -p 2222 git@git.dev.tailnet.example # greets you by username
ss -tln | grep 2222 # 100.x.y.z:2222, not 0.0.0.0Then push to a test repository over SSH, and reboot once to confirm Forgejo comes back with the tailnet-bound port.
- 17 Oct 2026First written.
- 23 Oct 2026Forgejo 15. git over SSH bound to the tailnet address, with ip_nonlocal_bind for boot. Added the boot-order seam.