Problem
GitHub started training Copilot models on the code it hosts. Although you can opt out (under Settings - Privacy), for my personal notes, prototypes, and homelab configs that’s a deal-breaker — I want a private git server that lives entirely inside my LAN, never reachable from the public internet, but still accessible with the convenience of a normal git push.
Gitea is the obvious pick: small, single-binary (or single container), no JVM, web UI close enough to GitHub to feel familiar.
My setup at a glance
- Proxmox host running a dedicated Docker VM.
- Gitea and Nginx Proxy Manager (NPM) as containers on the same user-defined Docker network (
proxynet). - NPM terminates TLS for
git.example.duckdns.orgusing a Let’s Encrypt certificate (DNS-01 challenge against DuckDNS — no port-forwarding needed). - pfSense does a host override so the same hostname resolves to the Docker VM’s LAN IP from inside the network.
- No port-forwards on the router. The site is unreachable from the internet by design. I will only push changes when on my home LAN.
Prerequisites
- A working Docker host with NPM already running and bound to the
proxynetnetwork. - A DuckDNS subdomain (or any DNS you control) — used only for cert issuance and a memorable hostname.
- pfSense (or any internal DNS) capable of host overrides.
docker-compose.yml
networks:
proxynet:
external: true
services:
server:
image: docker.gitea.com/gitea:1.26.1
container_name: gitea
restart: always
environment:
- USER_UID=1000
- USER_GID=1000
- GITEA__server__DOMAIN=git.example.duckdns.org
- GITEA__server__ROOT_URL=https://git.example.duckdns.org/
- GITEA__server__SSH_DOMAIN=git.example.duckdns.org
- GITEA__server__SSH_PORT=222
- GITEA__server__SSH_LISTEN_PORT=22
networks:
- proxynet
volumes:
- ./gitea:/data
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
ports:
- "222:22"
Two things worth pointing at:
networks.proxynet.external: true— NPM created this network when it came up; gitea joins it so NPM can reachhttp://gitea:3000by container name. No need to publish port 3000 on the host."222:22"— gitea’s built-in SSH server listens on container port 22; we expose it on the host as 222 to keep out of the way of the host’s own sshd.
The GITEA__server__* env vars matter when SSH is on a non-standard port. SSH_LISTEN_PORT=22 is what gitea actually binds inside the container; SSH_PORT=222 is what the web UI prints in clone URLs. Without these, the UI happily shows you a clone URL that doesn’t work.
NPM proxy host
In NPM, add a proxy host:
- Domain:
git.example.duckdns.org - Forward Hostname / IP:
gitea(the container name) - Forward Port:
3000 - Block Common Exploits and Websockets Support: on
- SSL: request a new Let’s Encrypt cert (DNS-01 against DuckDNS, since 80/443 aren’t exposed publicly). In my case I have a wildcard certificate that I use for all of the hosts.
NPM is now responsible for HTTPS termination. Gitea itself happily serves plain HTTP on 3000 inside the docker network.
pfSense host override
In pfSense → Services → DNS Resolver → Host Overrides, add:
- Host:
git - Domain:
example.duckdns.org - IP: the LAN IP of the Docker VM
Now git.example.duckdns.org resolves to your Docker VM from inside the LAN. From outside the LAN, the public DuckDNS record still points to your WAN IP — but with no port-forward, nothing answers. Exactly what we want.
First-run Gitea
Hit https://git.example.duckdns.org and walk through the install screen. The defaults are mostly fine. Two things I changed immediately:
- Disable open registration (
Allow Registrationoff). - Create the administrator account at the bottom of the install page rather than relying on the first-registered-user-becomes-admin fallback.
The SSH gotcha
NPM only proxies HTTP and HTTPS. SSH traffic does not go through NPM — it goes straight to the Docker VM on port 222 (the host port we published).
That means clone URLs need the explicit ssh:// form with the port:
ssh://git@git.example.duckdns.org:222/your-user/your-repo.git
The shorthand git@host:user/repo.git form silently assumes port 22 and will hang on a closed port. Gitea’s web UI prints the correct URL only if you set SSH_PORT=222 as shown above.
Adding and verifying your SSH key
Settings → SSH / GPG Keys → Add Key, paste your public key (~/.ssh/id_ed25519.pub). Gitea then asks you to prove you own the matching private key by signing a challenge string it generates.
The instructions on that page show a cmd.exe one-liner. If you’re in bash (Git Bash, WSL, Linux), use this instead:
echo -n 'CHALLENGE_STRING_FROM_GITEA' | ssh-keygen -Y sign -n gitea -f ~/.ssh/id_ed25519
Paste the entire -----BEGIN SSH SIGNATURE----- … -----END SSH SIGNATURE----- block back into Gitea. The key flips from “Unverified” to “Verified”.
Pushing your first repo
Create the repo in Gitea, then in your local clone:
git remote set-url origin ssh://git@git.example.duckdns.org:222/your-user/your-repo.git
git push -u origin main
If you imported the repo from GitHub as a mirror in Gitea, pushes will fail with Mirror Repository … is read-only. Convert it to a regular repo (or delete and recreate as a normal repo) before pushing.
TIP: There is a Gitea Mirror project that makes it super easy to mirror your repositories from GitHub.
Pitfalls I hit
Connection refused— make sure the docker port mapping (222:22) actually matches the port in your clone URL and any router-level rules. Mine started on 2222, ended on 222; both clone URL andssh -pargument have to track that.Host key verification failedwith no prompt — happens when SSH config setsStrictHostKeyChecking=yes. First connection:ssh -o StrictHostKeyChecking=accept-new -p 222 git@git.example.duckdns.org. It records the host key and you’re done.- Stale
known_hostsentry if the gitea container’s host key was regenerated:ssh-keygen -R '[git.example.duckdns.org]:222', then connect again. - Wrong clone URLs in the UI — if Gitea shows
git@git.example.duckdns.org:user/repo.git(no port, shorthand form), theSSH_PORTenv var isn’t set. Add it and restart the container.
What’s next
Two things on my own list:
- Backups of the
./giteavolume — sqlite DB plus repo storage. A nightly tarball into the Proxmox backup target is enough for a personal setup. - Remote access from a laptop on the road — for me it could be a WireGuard tunnel back into the VLAN where the VM resides, which keeps gitea unreachable from the public internet while still letting me push from anywhere. However this is needed only in cases when I do not have access to my home LAN for a long time.