Gitea vs Forgejo vs Gogs: Self-Hosted Git Platforms (2026)
TL;DR
Gitea is the lightweight self-hosted Git service — GitHub-like interface, built-in CI/CD (Gitea Actions), package registry, organizations, projects, fast and resource-efficient. Forgejo is the community-driven Gitea fork — same features plus federation (ActivityPub), stronger governance, soft-fork that tracks Gitea upstream. Gogs is the painless self-hosted Git — minimal, single binary, ultra-lightweight, SSH/HTTP, the original "easy self-hosted Git." In 2026: Gitea for feature-rich self-hosted Git, Forgejo for community-governed Git with federation, Gogs for the simplest possible Git server.
Key Takeaways
- Gitea: 46K+ GitHub stars — CI/CD, packages, projects, Actions runner
- Forgejo: 7K+ stars on Codeberg — Gitea fork, federation, community-governed
- Gogs: 45K+ GitHub stars — minimal, single binary, ultra-light
- Gitea has the most features including GitHub Actions-compatible CI/CD
- Forgejo adds federation (ActivityPub) for cross-instance collaboration
- Gogs provides the simplest, most lightweight Git hosting
Gitea
Gitea — lightweight self-hosted Git:
Installation
# Docker:
docker run -d \
--name gitea \
-p 3000:3000 \
-p 2222:22 \
-v gitea-data:/data \
-v /etc/timezone:/etc/timezone:ro \
gitea/gitea:latest
# Docker Compose:
# docker-compose.yml
version: "3.8"
services:
gitea:
image: gitea/gitea:latest
environment:
- USER_UID=1000
- USER_GID=1000
- GITEA__database__DB_TYPE=postgres
- GITEA__database__HOST=db:5432
- GITEA__database__NAME=gitea
- GITEA__database__USER=gitea
- GITEA__database__PASSWD=secret
- GITEA__server__ROOT_URL=https://git.example.com
- GITEA__server__SSH_DOMAIN=git.example.com
- GITEA__server__SSH_PORT=2222
volumes:
- gitea-data:/data
- /etc/timezone:/etc/timezone:ro
ports:
- "3000:3000"
- "2222:22"
depends_on:
- db
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: gitea
POSTGRES_PASSWORD: secret
POSTGRES_DB: gitea
volumes:
- postgres-data:/var/lib/postgresql/data
volumes:
gitea-data:
postgres-data:
API usage
// Gitea REST API:
const GITEA_URL = "https://git.example.com"
const GITEA_TOKEN = process.env.GITEA_TOKEN!
const headers = {
Authorization: `token ${GITEA_TOKEN}`,
"Content-Type": "application/json",
}
// List repositories:
const repos = await fetch(`${GITEA_URL}/api/v1/repos/search?limit=20`, {
headers,
}).then((r) => r.json())
// Create repository:
const newRepo = await fetch(`${GITEA_URL}/api/v1/user/repos`, {
method: "POST",
headers,
body: JSON.stringify({
name: "my-project",
description: "A new project",
private: false,
auto_init: true,
default_branch: "main",
license: "MIT",
gitignores: "Node",
}),
}).then((r) => r.json())
// Create issue:
const issue = await fetch(
`${GITEA_URL}/api/v1/repos/myorg/my-project/issues`,
{
method: "POST",
headers,
body: JSON.stringify({
title: "Fix package detection",
body: "The package scanner misses TypeScript packages.",
labels: [1, 3],
assignees: ["developer1"],
}),
}
).then((r) => r.json())
// Create pull request:
const pr = await fetch(
`${GITEA_URL}/api/v1/repos/myorg/my-project/pulls`,
{
method: "POST",
headers,
body: JSON.stringify({
title: "Add package scanner improvements",
body: "Fixes #42\n\nAdds TypeScript package detection.",
head: "feature/ts-detection",
base: "main",
assignees: ["reviewer1"],
labels: [2],
}),
}
).then((r) => r.json())
// Merge pull request:
await fetch(
`${GITEA_URL}/api/v1/repos/myorg/my-project/pulls/${pr.number}/merge`,
{
method: "POST",
headers,
body: JSON.stringify({
Do: "squash",
merge_message_field: "Add package scanner improvements (#43)",
delete_branch_after_merge: true,
}),
}
)
// Create webhook:
await fetch(`${GITEA_URL}/api/v1/repos/myorg/my-project/hooks`, {
method: "POST",
headers,
body: JSON.stringify({
type: "gitea",
config: {
url: "https://api.example.com/webhooks/gitea",
content_type: "json",
secret: "webhook-secret",
},
events: ["push", "pull_request", "issues"],
active: true,
}),
})
Gitea Actions (CI/CD)
# .gitea/workflows/ci.yml — GitHub Actions compatible!
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm test
- run: pnpm build
deploy:
needs: test
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- name: Build and push Docker image
run: |
docker build -t registry.example.com/my-project:${{ github.sha }} .
docker push registry.example.com/my-project:${{ github.sha }}
- name: Deploy
run: |
curl -X POST "https://deploy.example.com/api/deploy" \
-H "Authorization: Bearer ${{ secrets.DEPLOY_TOKEN }}" \
-d '{"image": "registry.example.com/my-project:${{ github.sha }}"}'
Package registry
# Gitea has a built-in package registry:
# npm packages:
npm config set @myorg:registry https://git.example.com/api/packages/myorg/npm/
npm config set //git.example.com/api/packages/myorg/npm/:_authToken ${GITEA_TOKEN}
npm publish
# Docker images:
docker login git.example.com
docker build -t git.example.com/myorg/my-project:latest .
docker push git.example.com/myorg/my-project:latest
# Supported formats: npm, PyPI, Maven, NuGet, Cargo, Container, Helm, etc.
Forgejo
Forgejo — community-driven Git forge:
Installation
# Docker:
docker run -d \
--name forgejo \
-p 3000:3000 \
-p 2222:22 \
-v forgejo-data:/data \
codeberg.org/forgejo/forgejo:latest
# docker-compose.yml
version: "3.8"
services:
forgejo:
image: codeberg.org/forgejo/forgejo:latest
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=secret
- FORGEJO__server__ROOT_URL=https://forge.example.com
- FORGEJO__federation__ENABLED=true # Enable federation!
volumes:
- forgejo-data:/data
ports:
- "3000:3000"
- "2222:22"
depends_on:
- db
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: forgejo
POSTGRES_PASSWORD: secret
POSTGRES_DB: forgejo
volumes:
- postgres-data:/var/lib/postgresql/data
volumes:
forgejo-data:
postgres-data:
API (Gitea-compatible)
// Forgejo API — compatible with Gitea API:
const FORGEJO_URL = "https://forge.example.com"
const FORGEJO_TOKEN = process.env.FORGEJO_TOKEN!
const headers = {
Authorization: `token ${FORGEJO_TOKEN}`,
"Content-Type": "application/json",
}
// All Gitea API endpoints work:
// List repos:
const repos = await fetch(`${FORGEJO_URL}/api/v1/repos/search`, {
headers,
}).then((r) => r.json())
// Create repo:
const repo = await fetch(`${FORGEJO_URL}/api/v1/user/repos`, {
method: "POST",
headers,
body: JSON.stringify({
name: "my-project",
description: "Built on Forgejo",
private: false,
auto_init: true,
default_branch: "main",
}),
}).then((r) => r.json())
// Create organization:
const org = await fetch(`${FORGEJO_URL}/api/v1/orgs`, {
method: "POST",
headers,
body: JSON.stringify({
username: "myteam",
full_name: "My Team",
description: "Development team",
visibility: "public",
}),
}).then((r) => r.json())
// Manage teams:
await fetch(`${FORGEJO_URL}/api/v1/orgs/myteam/teams`, {
method: "POST",
headers,
body: JSON.stringify({
name: "developers",
permission: "write",
units: ["repo.code", "repo.issues", "repo.pulls"],
}),
})
Federation (ActivityPub)
// Forgejo federation — follow users/repos across instances:
// Star a repository on another Forgejo instance:
// Users can follow repos on remote instances via ActivityPub
// Example: user@forge-a.example.com can star repo@forge-b.example.com
// The federation API exposes ActivityPub endpoints:
// GET /api/v1/activitypub/user-id/{userId}
// GET /api/v1/activitypub/repository-id/{repoId}
// Check federation status:
const federationInfo = await fetch(
`${FORGEJO_URL}/api/v1/nodeinfo`,
{ headers }
).then((r) => r.json())
// Federation enables:
// - Cross-instance repository starring
// - Cross-instance user following
// - Federated pull requests (in development)
// - Distributed issue tracking (planned)
Forgejo Actions (CI/CD)
# .forgejo/workflows/ci.yml — same as Gitea Actions
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
build:
runs-on: docker
container:
image: node:20-alpine
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm test
- run: npm run build
# Setup Forgejo Runner:
docker run -d \
--name forgejo-runner \
-v /var/run/docker.sock:/var/run/docker.sock \
-e FORGEJO_URL=https://forge.example.com \
-e FORGEJO_TOKEN=runner-registration-token \
codeberg.org/forgejo/runner:latest
Gogs
Gogs — painless self-hosted Git:
Installation
# Docker (simplest):
docker run -d \
--name gogs \
-p 3000:3000 \
-p 2222:22 \
-v gogs-data:/data \
gogs/gogs:latest
# Binary (single file):
wget https://dl.gogs.io/0.13.0/gogs_0.13.0_linux_amd64.tar.gz
tar -xzf gogs_0.13.0_linux_amd64.tar.gz
cd gogs
./gogs web
# docker-compose.yml — minimal setup
version: "3.8"
services:
gogs:
image: gogs/gogs:latest
ports:
- "3000:3000"
- "2222:22"
volumes:
- gogs-data:/data
environment:
- RUN_CROND=true
# Gogs works with SQLite (no external DB needed):
# Or use PostgreSQL/MySQL for production:
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: gogs
POSTGRES_PASSWORD: secret
POSTGRES_DB: gogs
volumes:
- postgres-data:/var/lib/postgresql/data
volumes:
gogs-data:
postgres-data:
API usage
// Gogs REST API:
const GOGS_URL = "https://git.example.com"
const GOGS_TOKEN = process.env.GOGS_TOKEN!
const headers = {
Authorization: `token ${GOGS_TOKEN}`,
"Content-Type": "application/json",
}
// List user's repos:
const repos = await fetch(`${GOGS_URL}/api/v1/user/repos`, {
headers,
}).then((r) => r.json())
// Create repository:
const repo = await fetch(`${GOGS_URL}/api/v1/user/repos`, {
method: "POST",
headers,
body: JSON.stringify({
name: "my-project",
description: "A simple project",
private: false,
auto_init: true,
}),
}).then((r) => r.json())
// Create issue:
const issue = await fetch(
`${GOGS_URL}/api/v1/repos/myuser/my-project/issues`,
{
method: "POST",
headers,
body: JSON.stringify({
title: "Bug report",
body: "Found a bug in the parser",
}),
}
).then((r) => r.json())
// Add collaborator:
await fetch(
`${GOGS_URL}/api/v1/repos/myuser/my-project/collaborators/otheruser`,
{
method: "PUT",
headers,
body: JSON.stringify({
permission: "write",
}),
}
)
// Create webhook:
await fetch(`${GOGS_URL}/api/v1/repos/myuser/my-project/hooks`, {
method: "POST",
headers,
body: JSON.stringify({
type: "gogs",
config: {
url: "https://api.example.com/webhooks/gogs",
content_type: "json",
secret: "webhook-secret",
},
events: ["push", "create", "pull_request"],
active: true,
}),
})
Configuration
; custom/conf/app.ini — Gogs configuration
[server]
DOMAIN = git.example.com
HTTP_PORT = 3000
ROOT_URL = https://git.example.com/
DISABLE_SSH = false
SSH_PORT = 2222
OFFLINE_MODE = false
[database]
DB_TYPE = postgres
HOST = db:5432
NAME = gogs
USER = gogs
PASSWD = secret
SSL_MODE = disable
[repository]
ROOT = /data/git/repositories
DEFAULT_BRANCH = main
[security]
INSTALL_LOCK = true
SECRET_KEY = your-secret-key
[service]
REGISTER_EMAIL_CONFIRM = false
ENABLE_NOTIFY_MAIL = false
DISABLE_REGISTRATION = false
REQUIRE_SIGNIN_VIEW = false
[mailer]
ENABLED = false
[log]
MODE = console
LEVEL = Info
ROOT_PATH = /app/gogs/log
Webhook handler
// Handle Gogs webhooks in your app:
import express from "express"
import crypto from "crypto"
const app = express()
app.use(express.json())
function verifyGogsSignature(
payload: string,
signature: string,
secret: string
): boolean {
const expected = crypto
.createHmac("sha256", secret)
.update(payload)
.digest("hex")
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
)
}
app.post("/webhooks/gogs", (req, res) => {
const signature = req.headers["x-gogs-signature"] as string
const event = req.headers["x-gogs-event"] as string
if (!verifyGogsSignature(JSON.stringify(req.body), signature, "webhook-secret")) {
return res.status(401).json({ error: "Invalid signature" })
}
switch (event) {
case "push":
const { ref, commits, repository } = req.body
console.log(`Push to ${repository.full_name}:${ref}`)
commits.forEach((c: any) => console.log(` ${c.id.slice(0, 7)} ${c.message}`))
break
case "pull_request":
const { action, pull_request } = req.body
console.log(`PR ${action}: ${pull_request.title}`)
break
case "issues":
console.log(`Issue ${req.body.action}: ${req.body.issue.title}`)
break
}
res.json({ received: true })
})
app.listen(4000)
Feature Comparison
| Feature | Gitea | Forgejo | Gogs |
|---|---|---|---|
| Language | Go | Go | Go |
| Origin | Gogs fork (2016) | Gitea fork (2022) | Original (2014) |
| Built-in CI/CD | ✅ (Gitea Actions) | ✅ (Forgejo Actions) | ❌ |
| GitHub Actions compat | ✅ | ✅ | ❌ |
| Package registry | ✅ (npm, Docker, PyPI, etc.) | ✅ | ❌ |
| Projects/Kanban | ✅ | ✅ | ❌ |
| Federation | ❌ | ✅ (ActivityPub) | ❌ |
| Organizations | ✅ | ✅ | ✅ |
| Pull requests | ✅ | ✅ | ✅ |
| Code review | ✅ | ✅ | Basic |
| Wiki | ✅ | ✅ | ✅ |
| Issue tracker | ✅ | ✅ | ✅ |
| Webhooks | ✅ | ✅ | ✅ |
| OAuth2 provider | ✅ | ✅ | ❌ |
| LDAP/AD | ✅ | ✅ | ✅ |
| Mirror repos | ✅ | ✅ | ✅ |
| LFS support | ✅ | ✅ | ✅ |
| Resource usage | Low (~200MB RAM) | Low (~200MB RAM) | Minimal (~50MB RAM) |
| SQLite support | ✅ | ✅ | ✅ |
| Governance | Company (Gitea Ltd) | Community (Codeberg) | Maintainer |
When to Use Each
Use Gitea if:
- Want the most feature-rich self-hosted Git with built-in CI/CD
- Need GitHub Actions-compatible workflows on your own infrastructure
- Want a built-in package registry (npm, Docker, PyPI, etc.)
- Building a complete DevOps platform with projects and kanban boards
Use Forgejo if:
- Want community-governed open-source Git hosting
- Need federation (ActivityPub) for cross-instance collaboration
- Prefer the same features as Gitea with stronger governance guarantees
- Supporting the decentralized, federated software forge movement
Use Gogs if:
- Want the simplest, most lightweight self-hosted Git server
- Need a single binary with minimal resource usage (~50MB RAM)
- Only need basic Git hosting (repos, issues, PRs, webhooks)
- Running on constrained hardware (Raspberry Pi, small VPS)
Methodology
GitHub/Codeberg stars as of March 2026. Feature comparison based on Gitea v1.22.x, Forgejo v9.x, and Gogs v0.13.x.