Docker Compose stack for a self-hosted Forgejo with a Forgejo Actions runner
  • Python 70.8%
  • Shell 29.2%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-08-18 16:35:02 +00:00
config Expose blog, Forgejo, and dashboard on public HTTPS domains 2026-08-18 11:46:20 +00:00
nginx/conf.d Harden nginx with security headers and TLS config 2026-08-18 16:35:02 +00:00
scripts Added migration script for Gitea to Forgejo migration. 2026-08-17 17:55:56 +00:00
.gitignore Expose blog, Forgejo, and dashboard on public HTTPS domains 2026-08-18 11:46:20 +00:00
docker-compose.yml Expose blog, Forgejo, and dashboard on public HTTPS domains 2026-08-18 11:46:20 +00:00
migrate-repos.sh Expose blog, Forgejo, and dashboard on public HTTPS domains 2026-08-18 11:46:20 +00:00
prepare.sh Initial commit: Forgejo + nginx + runner docker-compose stack 2026-08-12 16:34:43 +00:00
README.md Expose blog, Forgejo, and dashboard on public HTTPS domains 2026-08-18 11:46:20 +00:00

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 A records for git.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.sh needs root (chowns the runner data dir to uid 1001).

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:

  1. Create admin user — username + password (this replaces the old PASSWORD env var).
  2. Configuration — set the server host (the WG_HOST equivalent) to your public IP or DDNS hostname (a placeholder like vpn.example.com is fine to start), the client DNS to 192.168.1.1 (LAN router, which you should point at git/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.
  3. 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 Endpoint line 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:
    docker compose stop wg-easy
    docker compose rm -f wg-easy
    mv wg-easy wg-easy.v14.bak
    docker compose up -d wg-easy
    
    To keep existing peers instead, back up wg0.json from 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 permitted on startup — the host kernel lacks WireGuard. Ubuntu ≥ 21.04 has it built in; older hosts need wireguard-tools or the wireguard DKMS module.
  • Clients connect but nothing routes — confirm forwarding inside the container: docker exec wg-easy sysctl net.ipv4.ip_forward, and check docker exec wg-easy wg show shows the wg0 interface with a peer.
  • Handshake works on LAN but not from outside — router port-forward for 51820/udp is missing/misconfigured, or you're behind CGNAT (compare curl ifconfig.me with the router's WAN IP; if they differ, the ISP uses CGNAT and port-forwarding won't work).
  • Ports unreachable51820/udp (tunnel) and 51821/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 AllowedIPs must include 192.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; see nginx/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/blog so job containers can publish into it.

Astro-side requirements

  • Serve at the domain root: site: 'https://its.pkhamre.com' and base: '/' (no subpath) in astro.config.mjs (also set trailingSlash to taste). Hard-coded /blog/foo links 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 uid 1001 (the runner's user). Use sudo or the --user 1001:1001 pattern 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 bare register (without --config) wrote to default settings, not to runner-config.yml. Re-run step 6.
  • Forgejo is not supposed to be run as root — use docker compose exec -u 1000:1000 for 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's 22. 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 run docker compose exec nginx nginx -s reload again once certbot/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 the return 301; it does in nginx/conf.d/*.pkhamre.com.conf. Check the certbot webroot is mounted (docker compose exec nginx ls /var/www/certbot).