Self-hosting Forgejo with CI/CD
Why self-host a Git forge?
Replicate this if: you have a VPS with Docker + a reverse proxy (see your first VPS first). Swap
git.cloud.debnerd.infor your own domain. Everything else is copy-paste.
GitHub is great. But I wanted:
- Full control over my code, my data, my CI/CD
- No vendor lock-in — code lives on my VPS, mirrored to GitHub
- Friends can register via Google/GitHub OIDC and push code
- CI/CD that actually runs on my infrastructure, not someone else’s
Forgejo is a lightweight, self-hosted Gitea fork. It does everything GitHub does — issues, PRs, Actions CI/CD, OIDC, packages — and runs on a single Docker Compose stack.
Architecture
Internet → Pangolin/Traefik (TLS) → Forgejo (:3000)
→ SSH (:2222)
Forgejo ←── DinD runner ←── Docker-in-Docker daemon
│
└── PostgreSQL (pangolin_frontend only)
Key decision: Docker-in-Docker (DinD) for CI/CD, not host Docker socket.
This isolates CI jobs from the host — they can’t see your other containers or
data. The tradeoff is that CI containers can’t resolve internal hostnames
(like forgejo), so the runner config uses the public URL.
Prerequisites
- A Linux server with Docker + Docker Compose
- A domain name with a DNS record pointing at your server
- A reverse proxy in front (Pangolin, Caddy, nginx) to terminate TLS
- (Optional) Google/GitHub OAuth apps for OIDC login
- (Optional) Brevo account for email (free 300/day)
Directory layout
forgejo/
├── compose.yml # main stack
├── .env # POSTGRES_PASSWORD (chmod 600)
├── runner-config.yml # runner labels + server connection
├── data/ # Forgejo app data
├── postgres-data/ # PostgreSQL data
└── runner-data/ # runner cache
compose.yml
name: forgejo
services:
forgejo:
container_name: forgejo
image: codeberg.org/forgejo/forgejo:16
restart: unless-stopped
depends_on:
db:
condition: service_healthy
environment:
- USER_UID=1000
- USER_GID=1000
- FORGEJO__database__DB_TYPE=postgres
- FORGEJO__database__HOST=db:5432
- FORGEJO__database__NAME=forgejo
- FORGEJO__database__USER=forgejo
- FORGEJO__database__PASSWD=${POSTGRES_PASSWORD}
- FORGEJO__server__ROOT_URL=https://git.cloud.debnerd.in
- FORGEJO__server__HTTP_PORT=3000
- FORGEJO__server__DOMAIN=git.cloud.debnerd.in
- FORGEJO__server__SSH_DOMAIN=git.cloud.debnerd.in
- FORGEJO__server__SSH_LISTEN_PORT=22
- FORGEJO__server__SSH_PORT=2222
# OIDC
- FORGEJO__openid__ENABLE_OPENID_SIGNIN=false
- FORGEJO__openid__ENABLE_OPENID_SIGNUP=false
volumes:
- ./data:/data
ports:
- "2222:22"
networks:
pangolin_frontend:
db:
container_name: forgejo-db
image: postgres:16-alpine
restart: unless-stopped
environment:
- POSTGRES_USER=forgejo
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- POSTGRES_DB=forgejo
volumes:
- ./postgres-data:/var/lib/postgresql/data
networks:
pangolin_frontend:
healthcheck:
test: ["CMD-SHELL", "pg_isready -U forgejo"]
interval: 10s
timeout: 5s
retries: 5
dind:
image: docker:27-dind
container_name: forgejo-dind
privileged: true
restart: unless-stopped
command: ["dockerd", "-H", "tcp://0.0.0.0:2375", "--tls=false"]
networks:
pangolin_frontend:
runner:
container_name: forgejo-runner
image: data.forgejo.org/forgejo/runner:13
restart: unless-stopped
depends_on:
dind:
condition: service_started
links:
- dind
environment:
DOCKER_HOST: tcp://dind:2375
volumes:
- ./runner-data:/data
- ./runner-config.yml:/config.yml:ro
networks:
pangolin_frontend:
command: '/bin/sh -c "sleep 5; forgejo-runner daemon --config /config.yml"'
networks:
pangolin_frontend:
external: true
runner-config.yml
log:
level: info
job_level: info
runner:
file: .runner
capacity: 1
envs: {}
timeout: 3h
labels:
- "ubuntu-latest:docker://ghcr.io/catthehacker/ubuntu:runner-latest"
- "ubuntu-22.04:docker://ghcr.io/catthehacker/ubuntu:runner-22.04"
- "ubuntu-24.04:docker://ghcr.io/catthehacker/ubuntu:runner-24.04"
- "node-lts:docker://ghcr.io/catthehacker/ubuntu:runner-latest"
cache:
enabled: true
# DinD: CI containers run inside dockerd, can't resolve internal hostnames.
# Server URL must be the public address so git clones go through the proxy.
server:
connections:
git.cloud.debnerd.in:
url: https://git.cloud.debnerd.in
The runner labels map GitHub Actions runner names to actual Docker images.
When a workflow says runs-on: ubuntu-latest, Forgejo pulls
ghcr.io/catthehacker/ubuntu:runner-latest inside the DinD container.
First boot
-
Create
.env:echo "POSTGRES_PASSWORD=$(openssl rand -base64 32)" > .env chmod 600 .env -
Start the stack:
docker compose up -d -
Visit
https://git.cloud.debnerd.in— the first-run wizard creates your admin account and configures the database. -
During setup, check:
- Require email confirmation to register ✅
- Enable email notifications ✅
Register the runner
After the first-run wizard, the runner needs a registration token.
- Go to Site Administration → Actions → Runners
- Copy the registration token
- The runner will auto-connect on next restart (it waits 5 seconds for Forgejo to be ready)
Email setup (Brevo on port 2525)
I use a VPS provider that blocks outbound SMTP on ports 25, 587, and 465. Port 2525 is the only one that works. Brevo (formerly Sendinblue) supports it — free tier is 300 emails/day, no credit card.
- Sign up at brevo.com
- Go to SMTP & API → SMTP → create SMTP credentials
- In Forgejo, go to Site Administration → Configuration → SMTP Mailer
- Fill in:
- SMTP Server:
smtp-relay.brevo.com - SMTP Port:
2525 - Authentication: Normal password
- Username: (your Brevo SMTP login)
- Password: (your Brevo SMTP password)
- From Address:
noreply@your-domain.com - Enable TLS: STARTTLS
- SMTP Server:
Pitfall: Forgejo tries implicit TLS by default
After saving, test by clicking Forgot Password on the login page. If you get this error in the logs:
tls: first record does not look like a TLS handshake
Forgejo is trying port-465-style implicit TLS on port 2525. Fix by adding
PROTOCOL = smtp+starttls to the config:
docker exec forgejo sed -i '/^\[mailer\]/a PROTOCOL = smtp+starttls' /data/gitea/conf/app.ini
docker compose restart forgejo
The setup wizard doesn’t set this correctly. You have to patch it manually every time you re-deploy.
Domain verification for email
Brevo requires DNS records to verify your domain. Add these to your DNS provider:
Type Name Content
TXT git brevo-code:<your-code>
CNAME brevo1._domainkey.git b1.<your-domain>.dkim.brevo.com
CNAME brevo2._domainkey.git b2.<your-domain>.dkim.brevo.com
TXT _dmarc.git v=DMARC1; p=none; rua=mailto:rua@dmarc.brevo.com
CNAME noreply.git noreply-<your-domain>.brand.brevosend.com
CNAME r.noreply.git noreply-<your-domain>.r.brand.brevosend.com
CNAME img.noreply.git noreply-<your-domain>.img.brand.brevosend.com
All DNS-only (not proxied through Cloudflare). Click Verify in Brevo after adding them.
OIDC (Google + GitHub)
In Forgejo: Site Administration → Authentication → Add Authentication Source
Google OAuth
- Go to console.cloud.google.com
- Create a project → APIs & Services → OAuth consent screen → External
- Credentials → Create OAuth client ID → Web application
- Authorized redirect URI:
https://git.cloud.debnerd.in/user/oauth2/callback - Copy Client ID + Secret into Forgejo
GitHub OAuth
- Go to github.com/settings/developers
- New OAuth App
- Authorization callback URL:
https://git.cloud.debnerd.in/user/oauth2/callback - Copy Client ID + Client Secret into Forgejo
Push mirroring to GitHub
Since Forgejo is my primary, GitHub is just a mirror. But GitHub Pages deployment needs GitHub Actions, which needs pushes to GitHub. Push mirroring solves both.
Per-repo: Settings → Packages and Mirrors → Mirror Repository
- Address:
https://github.com/you/repo.git - Username: (your GitHub username)
- Password: (a PAT with
reposcope) - Enable Push Mirror ✅
- Sync on Commit ✅
Now: push to Forgejo → auto-mirrors to GitHub → GitHub Actions runs → Pages deploys. Everything just works.
Pitfalls
1. DinD can’t resolve internal hostnames
CI containers run inside Docker-in-Docker, which has its own network
namespace. They can’t reach forgejo, db, or any other container by name.
That’s why runner-config.yml uses the public URL — git clones go through
the reverse proxy (public DNS) and it works fine.
2. Runner data directory permissions
The runner container runs as uid 1000 (non-root). If runner-data/ is
owned by root, the runner can’t create its cache directory. Fix:
sudo chown -R 1000:1000 forgejo/runner-data
3. WebAuthn blocks API basic auth
If you enroll WebAuthn (hardware key / passkey) during setup, Forgejo disables basic auth for the API. You can’t use username/password for API calls. Either use the web UI for everything, or create an API token through Settings → Applications → Generate Token before enrolling WebAuthn.
4. Push-to-create is disabled by default
Unlike GitHub, Forgejo doesn’t let you push to a non-existent repo and have it auto-create. You must create repos through the web UI first, then push.
5. Providers may block SMTP ports
Some VPS providers block outbound TCP on ports 25, 587, and 465. Only port 2525 works on mine. This is a provider thing, not a Forgejo thing. If you’re on a different provider, standard ports should work fine.
What I learned
- Self-hosting a Git forge is simpler than I expected. The hard part isn’t the forge — it’s the CI/CD runners and email.
- DinD is the right choice for CI isolation, even if it adds a layer of complexity with hostname resolution.
- Email on a datacenter IP is a minefield. Use a relay service (Brevo, Mailgun, Resend) and port 2525 if your provider blocks the standard ones.
- Push mirroring is the bridge between self-hosted primary and public mirror. You don’t have to choose one or the other.
- Forgejo’s setup wizard doesn’t set the SMTP protocol correctly. Always
verify
PROTOCOL = smtp+starttlsis in the config.
Next
- Set up pull mirroring from GitHub repos I want to archive locally
- Explore Forgejo’s package registry (Container, npm, generic)
- Add more runners for parallel CI jobs
- Terraform module for the full stack (someday, really)