Blog
Why I'm Moving from GitLab to Forgejo (and Not Looking Back)
Why I'm Moving from GitLab to Forgejo (and Not Looking Back)🔗
TL;DR: GitLab is fantastic—but it's become massive. I want a forge that's small, fast, and easy to operate without eating a whole node's worth of RAM just to idle. Forgejo gives me the features I actually use, with a fraction of the footprint and fuss.
The problem: GitLab got heavy🔗
I've run self-hosted GitLab on and off for years. It does everything… which is also the problem. On a personal or small-team server, you end up running a constellation of services (Rails app, Sidekiq, Gitaly, Workhorse, Redis, PostgreSQL, Prometheus exporters, etc.). That's a lot to keep patched, backed up, and happy.
- Memory footprint: too big for what I need.
- Operational complexity: upgrades feel like little projects.
- Blast radius: one component flaking can make the whole experience feel brittle.
- "All-in-one" gravity: it nudges you to move everything into GitLab's way of doing things.
I don't want that anymore. I want a lean forge for repos, issues, wikis, and CI hooks—without a kitchen sink strapped to a jet engine.
Enter Forgejo🔗
Forgejo is a community-driven fork of Gitea focused on being open, small, and fast. That bias toward simplicity is exactly what I want:
- Low footprint: single lightweight server plus a database (SQLite or Postgres) and optional Redis.
- Batteries included: repos, issues, PRs, code search, releases, a Packages registry (including container images), Git LFS, OAuth, webhooks.
- Modern CI story: "Forgejo Actions" (GitHub Actions–compatible) + runners, or plug in Woodpecker/Drone if you prefer.
- Straightforward ops: easy Docker Compose; quick backups; fast restore.
This isn't about chasing novelty—it's about choosing a tool proportionate to the job.
What I actually need from a forge🔗
My day-to-day looks like this:
- Private and public repos
- Pull requests with code review
- Issue tracking + labels + milestones
- Release artifacts
- A small, sane CI (build/tests/lint for Rust, Go, Python)
- Container image hosting (nice to have)
- Webhooks to my own services
Forgejo checks these boxes without making my server feel like it's running a mini cloud provider.
My deployment (Docker Compose)🔗
I'm going with Postgres for durability and future growth, but you can happily run SQLite for ultra-minimal setups.
# docker-compose.yml
services:
forgejo:
image: codeberg.org/forgejo/forgejo:latest
container_name: forgejo
environment:
- USER_UID=1000
- USER_GID=1000
- FORGEJO__server__DOMAIN=git.mydomain.tld
- FORGEJO__server__ROOT_URL=https://git.mydomain.tld/
- FORGEJO__database__DB_TYPE=postgres
- FORGEJO__database__HOST=db:5432
- FORGEJO__database__NAME=forgejo
- FORGEJO__database__USER=forgejo
- FORGEJO__database__PASSWD=${FORGEJO_DB_PASSWORD}
- FORGEJO__cache__ENABLED=true
- FORGEJO__service__DISABLE_REGISTRATION=true
- FORGEJO__server__SSH_DOMAIN=git.mydomain.tld
- FORGEJO__server__START_SSH_SERVER=true
- FORGEJO__server__BUILTIN_SSH_SERVER_USER=git
- FORGEJO__actions__ENABLED=true
volumes:
- ./data:/data
ports:
- "3000:3000" # HTTP (behind reverse proxy)
- "222:22" # SSH
depends_on:
- db
restart: unless-stopped
db:
image: postgres:16
environment:
- POSTGRES_DB=forgejo
- POSTGRES_USER=forgejo
- POSTGRES_PASSWORD=${FORGEJO_DB_PASSWORD}
volumes:
- ./pgdata:/var/lib/postgresql/data
restart: unless-stopped
Reverse-proxy with Caddy/Traefik/Nginx, add TLS, and you're done.
CI without the bloat: Forgejo Actions🔗
Forgejo supports an Actions runner that's compatible with a big chunk of the GitHub Actions ecosystem. That means I can keep simple YAML workflows per repo—no need to wire up a giant CI system.
Example: Rust workflow
# .forgejo/workflows/rust.yml
name: rust-ci
on:
push:
pull_request:
jobs:
test:
runs-on: docker
steps:
- uses: actions/checkout@v4
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
- name: Cache cargo
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Build
run: cargo build --locked --all-targets
- name: Test
run: cargo test --locked --all -- --nocapture
Example: Go workflow
# .forgejo/workflows/go.yml
name: go-ci
on:
push:
pull_request:
jobs:
test:
runs-on: docker
steps:
- uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.22'
- name: Cache modules
uses: actions/cache@v4
with:
path: |
~/go/pkg/mod
~/.cache/go-build
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
- name: Test
run: go test ./... -v
Example: Python workflow
# .forgejo/workflows/python.yml
name: python-ci
on:
push:
pull_request:
jobs:
test:
runs-on: docker
steps:
- uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install deps
run: |
python -m pip install --upgrade pip wheel
pip install -r requirements.txt
- name: Lint & Test
run: |
python -m pip install ruff pytest
ruff check .
pytest -q
If I ever outgrow this, I can hang Woodpecker or Drone off webhooks—but I doubt I will.
Migration plan🔗
I want the move to be boring and reversible. My steps:
-
Inventory List all groups, repos, CI pipelines, container images, and LFS usage on GitLab. Decide what stays/goes.
-
Export projects
- Git:
git clone --mirrorfor each repo (preserves refs) - Issues/PRs: GitLab exports per project; some details may require a script or intermediary (there are community tools to convert issues—good enough for small projects).
- Packages/Container images: pull/push to the new registry, or rebuild and republish.
- Git:
-
Provision Forgejo Stand up the Docker Compose stack, configure users, orgs, and teams, set SSH keys and OAuth providers.
-
Import repos Push mirrored repos to Forgejo. Re-create branches and protections, labels, and milestones (I keep a small script for labels).
-
Set up CI Add
.forgejo/workflows/*.ymlto each project. Install a runner on my build node(s). -
Cutover Freeze GitLab writes, update remotes (
git remote set-url origin), flip webhook endpoints, and enable the registry. Announce the change to collaborators. -
Backup & retire Snapshot the Forgejo data volume and Postgres. Keep GitLab read-only for a short grace period, then shut it down.
Backups (simple and solid)🔗
- Postgres: nightly
pg_dump+ weekly base backup (or just snapshot the volume if your storage supports it). - Forgejo data dir: tar/rsync the
/datavolume (repos, attachments, avatars, LFS, actions logs). - Off-site: encrypted push to object storage.
- Restore drill: occasionally restore to a throwaway VM and verify logins, repos, and issues.
Small tools are easier to back up—and easier to actually restore when it matters.
Trade-offs I accept🔗
- GitLab's integrated everything is hard to beat if you truly use it all. I don't.
- Some "enterprise-y" features may need third-party tools or simple scripts in Forgejo. That's fine by me.
- Actions compat isn't 1:1 with GitHub-land, but it's good enough for my pipelines.
In return, I get a forge that starts fast, stays fast, and doesn't demand a dedicated ops sprint every time I upgrade.
Closing🔗
I still respect GitLab. It's a powerhouse. But for my work, Forgejo is the right size: small, friendly, and focused. I spend more time coding and less time babysitting infra—and that's the whole point.
If you're running a personal forge or a small team and your GitLab instance feels like a space heater, try Forgejo. Your CPU—and your brain—will cool off.
AI-assisted writing
I draft and edit all articles myself, and I use AI as an assistant for outlining, phrasing, and cleanup. Curious how I use it—and where I draw the lines?