You want off GitHub. Maybe you’re tired of Microsoft owning your code, maybe your company’s compliance team is breathing down your neck, or maybe you just hate paying $4/user/month for features you could run yourself on a $10 VPS. Whatever the reason, you’ve landed in self-hosted git territory.
The three realistic options you’ll encounter are Gitea, Forgejo, and GitLab CE. They’re not interchangeable. They solve the same core problem — hosting git repositories — but they’re built for different scales, different philosophies, and different pain tolerances. Picking the wrong one means either under-powered tooling six months from now or a server that needs 8GB RAM to render a diff.
This article walks through feature parity, real performance numbers, gotchas I’ve personally hit, and concrete migration paths between all three. No fluff.
What You’re Actually Choosing Between
Before the comparison, a quick orientation — because "Gitea fork" doesn’t tell the full story.
Gitea started as a fork of Gogs in 2016. It’s written in Go, ships as a single binary, and is genuinely one of the most pleasant pieces of self-hosted software to run. For years it was the default answer to "I want to self-host git." Then in late 2022, Gitea Ltd. was incorporated. A Gitea Inc. happened. The community got nervous, and a fork was born.
Forgejo is that fork. It launched in late 2022 as a soft fork — almost identical to Gitea at first — but it’s governed by the Codeberg e.V. non-profit and explicitly committed to FOSS values. It has since grown its own identity: federation via ActivityPub is the flagship differentiator, and the two codebases are diverging faster every quarter.
GitLab CE is a different animal entirely. It’s a Ruby on Rails + Go monolith that’s been evolving since 2011. It’s not a "git server with some features bolted on" — it’s a full DevSecOps platform. The CE (Community Edition) is the open-core free tier. The difference between Gitea/Forgejo and GitLab CE is roughly the difference between a fast sports car and a fire truck: one is agile and cheap to fuel, the other can handle an industrial incident but needs its own parking lot.
Official repos: Gitea · Forgejo · GitLab CE
Feature Parity: The Matrix Nobody Wants to Build
Let’s be concrete. Here’s where each platform actually stands today.
Core Git Hosting
All three handle the basics competently: push/pull, SSH and HTTPS, protected branches, branch rules, repository mirroring, and webhooks. This category is a draw. Don’t use it as a decision factor.
Pull Requests / Merge Requests
Gitea and Forgejo call them "Pull Requests" (GitHub-style). GitLab calls them "Merge Requests." Functionally similar. GitLab’s MR workflow is more mature for larger teams — inline suggestions that apply as commits, draft MRs with dependency blocking, approval rules per-file-path. Gitea/Forgejo have simpler approval flows that work fine for small teams but start showing cracks when you need "at least 2 reviewers from the security team must approve changes to /infra."
CI/CD
This was Gitea/Forgejo’s biggest weakness until recently. Gitea Actions launched in Gitea 1.19, and Forgejo ships Forgejo Actions — both are almost fully compatible with GitHub Actions syntax. Your existing .github/workflows/*.yml files work with minimal edits (change runs-on: ubuntu-latest to whatever your runner provides). The runner is a separate binary you deploy alongside the server.
GitLab CI is older, more battle-tested, and has a larger ecosystem of pre-built CI components. Its pipeline syntax (.gitlab-ci.yml) is powerful but has a steeper learning curve — include, extends, !reference, and rules interact in ways that’ll send you to the docs repeatedly. GitLab also has built-in runner autoscaling with Docker Machine and Kubernetes executor support out of the box.
Verdict: For GitHub Actions refugees, Gitea/Forgejo Actions is a smoother transition. For complex enterprise pipelines, GitLab CI’s maturity shows.
Container and Package Registries
GitLab CE ships a full container registry (Docker, OCI) and package registry (PyPI, npm, Maven, Go, Helm, and more) out of the box. It’s production-grade.
Gitea/Forgejo have a packages system supporting Docker, npm, PyPI, Maven, Cargo, Composer, and several others. It works, but it’s younger and occasionally rough. The Docker registry specifically requires a proxy cache configuration if you want docker pull to work transparently — it doesn’t advertise itself on port 443 by default unless you configure it.
Security Scanning
GitLab CE does include some security features — dependency scanning, SAST, secret detection — but the good stuff (DAST, container scanning, license compliance) is locked behind EE. Don’t buy into GitLab’s feature matrix without carefully reading which tier each item lives in. The CE tier gives you enough to feel secure but not enough to actually be secure in a regulated environment without additional tooling.
Gitea and Forgejo have no built-in security scanning. You handle this in CI — trivy, grype, semgrep, etc. This isn’t necessarily a disadvantage; integrating external tools gives you more control.
Federation (Forgejo Only)
This is Forgejo’s ace. ActivityPub federation means Forgejo instances can talk to each other — you can follow repositories, receive notifications, and in the future, submit pull requests across instances without having accounts on both. It’s still maturing (as of 2026 it’s functional but not all features are federated), but the direction is meaningful. No other self-hosted git platform has this.
If you’re building a community platform or care about the decentralized web, Forgejo is the only option worth considering.
Project Management
All three have issues, milestones, and kanban boards. GitLab’s project management is more comprehensive — epics, roadmaps, time tracking, and requirements management exist in CE. Gitea/Forgejo have lighter project boards that cover 80% of what most teams need without the complexity.
Authentication and SSO
All three support LDAP, OAuth2 (GitHub, Google, GitLab, generic OIDC), and SAML (GitLab only in CE for SAML; Gitea/Forgejo handle SAML via plugins or reverse proxy). For most self-hosted setups, OIDC with Authentik or Keycloak works on all three.
Real Performance Numbers
Benchmarks are lies, but resource consumption is not.
Gitea and Forgejo
On a $6/month VPS (1 vCPU, 1GB RAM):
- Idle RAM: 80–150MB
- Under load (50 concurrent git operations): peaks to ~300MB, CPU spikes to 40–60% then drops
- Startup time: under 3 seconds
- SQLite works for single-user or small teams (under 20 users). Switch to PostgreSQL before you hit 50 users — SQLite locking becomes a problem under concurrent CI load.
A single Gitea/Forgejo instance on a 2-core/2GB machine handles hundreds of active developers without issue. I’ve seen setups with 500+ repos and 100+ active users running comfortably on 4GB RAM with PostgreSQL.
GitLab CE
GitLab’s own documentation recommends 4 vCPU and 4GB RAM minimum, with 8GB RAM recommended for production. In practice:
- Idle RAM after startup: 1.8–2.5GB (Puma workers, Sidekiq, Gitaly, Redis, PostgreSQL — all bundled)
- Startup time: 2–4 minutes cold start
- Single-user testing: technically works at 2GB RAM with Puma workers set to 1, but you’ll be swapping under any real load
GitLab’s Omnibus package bundles PostgreSQL, Redis, Nginx, and Gitaly. This is convenient but means you’re running 8+ processes just to serve a git repo. The containerized install (docker-compose) spreads this across several containers and is easier to tune resource limits on.
Bottom line: If your server has less than 4GB RAM, GitLab CE will be a miserable experience. Gitea/Forgejo will run fine on a raspberry pi.
Docker Compose Quickstart
Here’s a minimal, production-usable compose file for each. Adjust volumes and environment variables to your setup.
Forgejo
# forgejo-compose.yml
services:
forgejo:
image: codeberg.org/forgejo/forgejo:9
container_name: forgejo
restart: unless-stopped
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=${DB_PASSWORD}
- FORGEJO__server__DOMAIN=git.example.com
- FORGEJO__server__ROOT_URL=https://git.example.com/
- FORGEJO__server__SSH_DOMAIN=git.example.com
volumes:
- forgejo-data:/data
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
ports:
- "3000:3000"
- "2222:22" # SSH — map to 22 on host or use 2222 with client config
depends_on:
- db
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
- POSTGRES_DB=forgejo
- POSTGRES_USER=forgejo
- POSTGRES_PASSWORD=${DB_PASSWORD}
volumes:
- postgres-data:/var/lib/postgresql/data
volumes:
forgejo-data:
postgres-data:
Gitea’s compose file is structurally identical — swap the image to gitea/gitea:latest and the environment prefix from FORGEJO__ to GITEA__.
GitLab CE
# gitlab-compose.yml
services:
gitlab:
image: gitlab/gitlab-ce:latest
container_name: gitlab
restart: unless-stopped
hostname: git.example.com
environment:
GITLAB_OMNIBUS_CONFIG: |
external_url 'https://git.example.com'
gitlab_rails['gitlab_shell_ssh_port'] = 2222
# Reduce Puma workers to save RAM on smaller boxes
puma['worker_processes'] = 2
# Use external PostgreSQL if you have one
# postgresql['enable'] = false
# gitlab_rails['db_adapter'] = 'postgresql'
# gitlab_rails['db_host'] = 'your-db-host'
nginx['listen_https'] = false # Let your reverse proxy handle TLS
nginx['listen_port'] = 80
ports:
- "80:80"
- "2222:22"
volumes:
- gitlab-config:/etc/gitlab
- gitlab-logs:/var/log/gitlab
- gitlab-data:/var/opt/gitlab
shm_size: '256m' # Required — GitLab uses shared memory
volumes:
gitlab-config:
gitlab-logs:
gitlab-data:
Put your reverse proxy (Caddy, Nginx, Traefik) in front of both. Never expose port 80/443 directly from the container in production.
Migration Paths
Gitea → Forgejo (Easiest Migration You’ll Ever Do)
The databases are schema-compatible up through Forgejo’s early versions. For recent versions:
- Stop Gitea, take a full PostgreSQL dump and a volume backup.
- Pull the Forgejo image and point it at the same database.
- On first start, Forgejo runs its own migrations against your existing schema.
- That’s it. Seriously.
Gotcha: If you’re on Gitea 1.22+ and Forgejo has diverged its schema, check Forgejo’s migration docs first. Running the wrong version order can cause irreversible schema mutations. Always test on a clone of the database, never on production directly.
Forgejo → Gitea (Getting Harder)
The reverse works the same way for now, but Forgejo’s federation tables and newer features won’t have equivalents in Gitea. Gitea will ignore unknown columns during startup (usually), but don’t count on it. This path gets riskier every quarter as the schemas diverge further.
Verdict: If you’re on Gitea and considering a move, go to Forgejo now while it’s still trivial. If you’re on Forgejo, going back to Gitea is already starting to feel like a downgrade anyway.
GitLab → Gitea/Forgejo
This is a one-way door with real data loss to plan for.
What migrates cleanly:
- Git repositories (Gitea has a built-in GitLab importer)
- Issues and comments (via Gitea’s migration UI)
- Labels and milestones
- Users (via API scripting)
What does not migrate:
- GitLab CI pipelines — you rewrite these as Actions YAML
- MR approval rules — reconfigure manually
- GitLab-specific features (epics, roadmaps, dependencies between issues)
- Container registry images — repush manually or write a script to mirror
The migration UI in Gitea/Forgejo is at Site Administration → Migration. For GitLab, it supports importing via Personal Access Token and will pull repos + issues in bulk. For large instances (hundreds of repos), use the API:
#!/usr/bin/env bash
# Bulk-migrate all repos from a GitLab group to Forgejo
# Requires: curl, jq
# Set these:
GITLAB_URL="https://gitlab.example.com"
GITLAB_TOKEN="glpat-xxxxxxxxxxxx"
FORGEJO_URL="https://git.example.com"
FORGEJO_TOKEN="your-forgejo-token"
FORGEJO_ORG="my-org"
GITLAB_GROUP_ID="42"
curl -s --header "PRIVATE-TOKEN: $GITLAB_TOKEN" \
"$GITLAB_URL/api/v4/groups/$GITLAB_GROUP_ID/projects?per_page=100" | \
jq -r '.[] | [.name, .http_url_to_repo] | @tsv' | \
while IFS=$'\t' read -r name clone_url; do
echo "Migrating: $name"
curl -s -X POST "$FORGEJO_URL/api/v1/repos/migrate" \
-H "Authorization: token $FORGEJO_TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"clone_addr\": \"$clone_url\",
\"auth_token\": \"$GITLAB_TOKEN\",
\"repo_name\": \"$name\",
\"repo_owner\": \"$FORGEJO_ORG\",
\"mirror\": false,
\"issues\": true,
\"labels\": true,
\"milestones\": true,
\"releases\": true
}"
sleep 1 # Be polite to your own servers
done
Gitea/Forgejo → GitLab
This direction is less common (usually people go the other way) but it happens when teams outgrow lightweight platforms.
GitLab has no native Gitea importer. Your options:
- Manual repo migration:
git clone --mirroreach repo, push to GitLab. - Issues: Export via Gitea API, transform to GitLab import format (CSV or JSON). GitLab’s issue importer accepts a specific CSV format.
- CI: Your Gitea Actions YAML is close enough to GitLab CI syntax in structure, but the keywords differ enough that you’re rewriting, not converting.
Budget a week of effort per 50 active repositories when doing this migration properly.
Gotchas Worth Knowing
Gitea/Forgejo Actions runner is not bundled. The server itself doesn’t run CI jobs. You deploy a separate act_runner binary (or Docker container) and register it. First-timers always miss this and wonder why their pipelines queue forever.
GitLab’s initial root password expires. On first install, GitLab generates a temporary root password stored in /etc/gitlab/initial_root_password. It expires after 24 hours. If you miss it, you need to reset via gitlab-rails console — annoying but recoverable.
Forgejo’s federation is opt-in and not enabled by default. You add [federation] ENABLED = true to app.ini. Don’t forget to expose the .well-known/ routes through your reverse proxy.
GitLab CE → EE migrations are easy; EE → CE is not. If you ever try EE on a trial and load EE-only data (epics, security scans, etc.), downgrading to CE corrupts those records. Don’t trial EE on your production database.
Gitea’s package registry storage grows silently. If you’re using it as a Docker registry or storing large packages, set MAX_SIZE limits and monitor the storage volume. I’ve seen servers fill up because nobody noticed the packages directory growing to 200GB.
SSH keys vs HTTPS tokens for CI runners. In GitLab, runners authenticate via a registration token tied to a project or group. In Gitea/Forgejo, runners use a shared secret from the admin panel. The scoping is different — plan your runner access model before registering runners everywhere.
The Honest Recommendation
Small team, limited hardware, or migrating from GitHub: Start with Forgejo. It’s where Gitea was heading anyway, the governance is better, and federation is a genuine long-term differentiator. You can run it on the cheapest VPS and have everything you need for a team of 20.
Enterprise team, already invested in DevOps tooling, need the full platform: GitLab CE is worth the resource overhead. The CI system is more mature, the MR approval model handles organizational complexity, and the audit trail features matter for compliance. Just don’t cheap out on the server — 8GB RAM, SSD storage, and a separate PostgreSQL instance are non-negotiable for anything beyond a pilot.
Already on Gitea: Move to Forgejo now, while it’s a 5-minute migration. Gitea’s corporate trajectory is uncertain; Forgejo’s community governance is not.
The worst outcome is choosing GitLab CE for a 5-person team because it "seemed more serious," then spending your Sundays babysitting a server that needs 3GB of RAM to run a linter. Match the platform to the actual problem.
Further Reading
- Forgejo documentation — particularly the federation and Actions runner setup
- GitLab CE vs EE feature comparison — read this before assuming a feature is in CE
- act_runner setup guide — Forgejo’s official runner docs
- GitLab performance tuning for small instances — saves your sanity if you’re running GitLab under 8GB