- Python 70.8%
- Shell 29.2%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| config | ||
| nginx/conf.d | ||
| scripts | ||
| .gitignore | ||
| docker-compose.yml | ||
| migrate-repos.sh | ||
| prepare.sh | ||
| README.md | ||
Forgejo + nginx + Forgejo Runner
A self-hosted Git forge with CI/CD, running on a single Docker Compose stack, exposed to the internet over HTTPS:
| Service | Container | Role |
|---|---|---|
nginx |
nginx:1.31-alpine |
Reverse proxy + TLS termination (ports 80/443) |
forgejo |
codeberg.org/forgejo/forgejo:16 |
Forgejo itself (SQLite, SSH on port 222) |
glance |
glanceapp/glance |
Personal dashboard |
docker-in-docker |
docker:dind |
Isolated daemon that runs CI job containers |
runner |
data.forgejo.org/forgejo/runner:13 |
Forgejo Actions runner, executes jobs via dind |
wg-easy |
ghcr.io/wg-easy/wg-easy:15 |
WireGuard VPN server, web UI on 51821, tunnel on 51820/udp |
certbot |
certbot/certbot |
Let's Encrypt webroot sidecar (profile certbot, one-off/renewal only) |
Public domains
| URL | Serves |
|---|---|
https://git.pkhamre.com/ |
Forgejo (web + SSH on port 222) |
https://its.pkhamre.com/ |
the blog (static Astro, built by CI) |
https://dash.pkhamre.com/ |
the Glance dashboard |
pies.lan is kept only as an nginx default_server catch-all (returns 404 for
unmatched Host headers).
Prerequisites
- Docker with the Compose plugin.
- DNS
Arecords forgit.pkhamre.com,its.pkhamre.com,dash.pkhamre.com→ your public IP (or → the host LAN IP if you use split-DNS for hairpin NAT). - Router port-forwards TCP 80, 443 → host LAN IP; host firewall allows them
(
sudo ufw allow 80/tcp; sudo ufw allow 443/tcp; sudo ufw allow 222/tcp). prepare.shneeds root (chowns the runner data dir to uid1001).
Quick start
Run every step from the stack directory (~/infra/forgejo-stack).
1. Prepare runner data directory
sudo ./prepare.sh
2. Start Forgejo + Docker-in-Docker
docker compose up -d forgejo docker-in-docker
Forgejo needs a config file before a runner can be registered, so complete the
first-run installer (admin account + DB) at https://git.pkhamre.com once the
stack is up. nginx isn't up yet — use http://<host-ip>:3000 if you need to
reach Forgejo before step 7.
3. Generate the runner config
Generate it inside runner-data/ as uid 1001 so the file has the right owner
and lands where the runner container expects it:
cd runner-data
docker run --rm -v "$PWD:/data" --user 1001:1001 \
data.forgejo.org/forgejo/runner:13 sh -c 'forgejo-runner generate-config > /data/runner-config.yml'
If you generated it elsewhere (e.g.
./runner-config.yml), move it into place instead:sudo mv runner-config.yml runner-data/runner-config.yml sudo chown 1001:1001 runner-data/runner-config.yml
4. Point job steps at the Docker-in-Docker daemon
Append DOCKER_HOST under the runner.envs block in runner-data/runner-config.yml:
cd ~/infra/forgejo-stack
sed -i 's/^\( envs:\)$/\1\n DOCKER_HOST: tcp:\/\/docker-in-docker:2375/' runner-data/runner-config.yml
Confirm it took:
grep -n -A1 'envs:' runner-data/runner-config.yml
Expected:
envs:
DOCKER_HOST: tcp://docker-in-docker:2375
5. Create a global runner token
The forgejo process runs as uid 1000, so exec with -u 1000:1000 (not root):
docker compose exec -u 1000:1000 forgejo forgejo forgejo-cli actions generate-runner-token
If this fails with "Unable to load config file for an installed Forgejo instance", you skipped the first-run installer in step 2.
6. Register the runner (global scope)
In runner v13 the register command is deprecated, so pass --config to persist
the connection declaratively into runner-config.yml. Re-running is safe (idempotent):
docker compose run --rm runner forgejo-runner register --config runner-config.yml \
--no-interactive --instance http://forgejo:3000 --token <TOKEN> --name runner
7. Start the full stack
docker compose up -d
docker compose ps
8. Verify
- Open
https://git.pkhamre.com— nginx terminates TLS and proxies to Forgejo. - The runner should appear under Site Administration → Actions → Runners.
- Let the daemon settle, then confirm it's polling:
docker compose logs -f runner
Look for [poller] launched and subsequent heartbeats.
TLS (Let's Encrypt)
Certificates are issued with the certbot sidecar via HTTP-01 (webroot). nginx
serves /.well-known/acme-challenge/ on port 80 and 301-redirects everything
else to HTTPS. Certificates, ACME account keys, and renewal config live in
./certbot/ (gitignored).
Issue a certificate
mkdir -p certbot/conf certbot/www
docker compose up -d nginx # picks up 443 + certbot mounts
docker compose run --rm certbot certonly --webroot -w /var/www/certbot -d <domain> --dry-run
docker compose run --rm certbot certonly --webroot -w /var/www/certbot -d <domain> \
--email youtube@pkhamre.com --agree-tos --no-eff-email
--webroot -w persists into certbot/conf/renewal/<domain>.conf, so plain
certbot renew reuses it. Repeat per domain (git, its, dash).
Renewal (host cron)
crontab -e
# renew twice a day (no-ops within 30 days of expiry), then graceful reload
17 3,15 * * * cd /workspace && docker compose run --rm certbot renew --quiet >> /var/log/certbot-renew.log 2>&1; docker exec nginx nginx -s reload
VPN (wg-easy)
WireGuard VPN for remote access, managed through a web UI on port 51821.
Full-tunnel mode (0.0.0.0/0, ::/0) so remote clients can reach the whole
LAN including the public services (git/its/dash.pkhamre.com via
split-DNS or LAN IP).
v15 note: wg-easy v15 is a rewrite — there are no environment variables for the server host, password, or DNS. Those are configured in the Web UI's setup wizard and admin panel (step 3 below). The compose file only sets
INSECURE=true, which permits the plain-HTTP UI access on port 51821. Add HTTPS (reverse proxy) before exposing the UI beyond your LAN.
1. Start
docker compose up -d wg-easy
docker compose ps wg-easy # should be (healthy)
docker compose logs -f wg-easy # look for the Web-UI URL line
2. Open the WireGuard port
- Router: port-forward
51820/udp→ the host's LAN IP (192.168.1.x). - Host firewall: allow it, e.g.
sudo ufw allow 51820/udp. Caveat: Docker publishes ports directly via iptables, which UFW's INPUT rules do not police, so the port may already be reachable — allow it anyway so host rules are explicit. If VPN routing stops working while UFW is active, check UFW's FORWARD policy (Docker manages its own forward chains).
3. Complete the first-run setup wizard
Open http://<host-ip>:51821 and follow the wizard:
- Create admin user — username + password (this replaces the old
PASSWORDenv var). - Configuration — set the server host (the
WG_HOSTequivalent) to your public IP or DDNS hostname (a placeholder likevpn.example.comis fine to start), the client DNS to192.168.1.1(LAN router, which you should point atgit/its/dash.pkhamre.com via split-DNS so VPN clients resolve them to the LAN IP and skip hairpin NAT), and leave the client subnet at its default (10.8.0.0/24). Answer "No" to importing an existing configuration. - The tunnel starts and the wizard offers to download the server config.
4. Add a client
From the admin panel, add a peer. Scan the QR code or download the config.
Full-tunnel routes and DNS 192.168.1.1 are baked in.
Changing the server host later only affects newly generated client configs — re-download or edit the
Endpointline on each client.
VPN troubleshooting
- Container restarts with "invalid Configuration … migrate from 14 to 15" —
the
./wg-easy/directory contains config from an older wg-easy release. Unless you need to keep existing peers, move it aside and start fresh:
To keep existing peers instead, back updocker compose stop wg-easy docker compose rm -f wg-easy mv wg-easy wg-easy.v14.bak docker compose up -d wg-easywg0.jsonfrom the old Web UI (or the volume) and upload it via the wizard's "existing configuration" option (https://wg-easy.github.io/wg-easy/latest/advanced/migrate/from-14-to-15/). Module 'wireguard' not found/Operation not permittedon startup — the host kernel lacks WireGuard. Ubuntu ≥ 21.04 has it built in; older hosts needwireguard-toolsor thewireguardDKMS module.- Clients connect but nothing routes — confirm forwarding inside the
container:
docker exec wg-easy sysctl net.ipv4.ip_forward, and checkdocker exec wg-easy wg showshows thewg0interface with a peer. - Handshake works on LAN but not from outside — router port-forward for
51820/udpis missing/misconfigured, or you're behind CGNAT (comparecurl ifconfig.mewith the router's WAN IP; if they differ, the ISP uses CGNAT and port-forwarding won't work). - Ports unreachable —
51820/udp(tunnel) and51821/tcp(UI) must be open in the host firewall and forwarded on the router. UDP isn't visible to typical TCP port scans, so test with a real client handshake. - LAN hosts unreachable through the tunnel — client
AllowedIPsmust include192.168.1.0/24(it does under full tunnel). If UFW is active, check its FORWARD policy.
Blog
The blog is a static Astro site served at https://its.pkhamre.com/. nginx
serves pre-built files straight off the host; CI (the Forgejo runner) builds
and publishes them on push. There is no server-side generation.
- Route:
https://its.pkhamre.com/(domain root; seenginx/conf.d/its.pkhamre.com-https.conf,root /usr/share/nginx/blog). - Host output dir:
~/infra/its.pkhamre.com— the Astro build output lands here (outside this repo, not gitignored). - nginx container: mounts it read-only at
/usr/share/nginx/blog. - docker-in-docker container: mounts it read-write at
/var/www/blogso job containers can publish into it.
Astro-side requirements
- Serve at the domain root:
site: 'https://its.pkhamre.com'andbase: '/'(no subpath) inastro.config.mjs(also settrailingSlashto taste). Hard-coded/blog/foolinks in content should become/foo. - Build output must land in the repo's
dist/(default).
Publishing via CI
The job container must bind-mount the output dir. Because jobs run under
docker-in-docker, the mount source /var/www/blog resolves to the dind
container's own mount (the host dir). Add to the workflow job:
jobs:
build:
runs-on: docker
container:
image: node:22
options: --volume /var/www/blog:/deploy
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm run build
- run: cp -r dist/. /deploy/
Deployed files are written as root (dind/job containers run as root). If you
later want the host user to edit them, pre-create the dir and chown it; note
the CI overwrite will still own them.
First deploy
mkdir -p ~/infra/its.pkhamre.com # Docker auto-creates it too, but own it yourself
docker compose up -d # picks up the new volumes
Until the first CI run, its.pkhamre.com serves a placeholder index.html
from the output dir.
Layout
├── docker-compose.yml # nginx, forgejo, dind, runner, wg-easy, certbot
├── nginx/conf.d/ # vhosts: pies.lan catch-all + *.pkhamre.com
├── config/glance.yml # Glance dashboard config
├── certbot/ # LE account keys + certs (gitignored)
├── prepare.sh # runner data dir setup (uid 1001)
├── forgejo/ # Forgejo data volume (gitignored)
├── runner-data/ # runner config + registration (gitignored)
└── wg-easy/ # WireGuard keys, peers, sqlite db (gitignored)
Blog build output lives at ~/infra/its.pkhamre.com (outside this repo).
Troubleshooting
runner-data/...: Permission denied— that dir is owned by uid1001(the runner's user). Usesudoor the--user 1001:1001pattern above to write into it.- Runner container fails to start before first boot — make sure you ran
prepare.sh(step 1) first. Error: runner: 0 server connections configured, terminating— the daemon flaps because a bareregister(without--config) wrote to default settings, not torunner-config.yml. Re-run step 6.Forgejo is not supposed to be run as root— usedocker compose exec -u 1000:1000for the forgejo CLI.500 Can't connect to pies.lan:80 (Connection refused)— nginx isn't up yet; run step 7.- SSH clone URLs advertise port
222— mapped from the Forgejo container's22. nginx only proxies HTTP, so SSH bypasses it. nginx: [emerg] ... ssl_certificate ... no such file— the HTTPS vhosts were added before the certs were issued. Either issue the certs first, or rundocker compose exec nginx nginx -s reloadagain oncecertbot/conf/live/<domain>exists.http://<domain>doesn't redirect / acme probe returns 301 — the HTTP vhost's/.well-known/acme-challenge/block must come before thereturn 301; it does innginx/conf.d/*.pkhamre.com.conf. Check the certbot webroot is mounted (docker compose exec nginx ls /var/www/certbot).