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)
Administration and Day-Two Operations
Feature selection at install time is only half the decision. The difference in operational burden between Gitea, Forgejo, and Gogs becomes clear when something goes wrong or when the team grows.
Gitea's administrative panel is the most comprehensive of the three. You can manage users, organizations, OAuth apps, SSH keys, banned email patterns, and running cron jobs from the web UI without touching the filesystem. Upgrade paths are well-documented and typically require only a container image swap followed by an automatic database migration on first boot. The admin API is complete enough that most operations can be scripted — user provisioning, repository creation, and team assignment all have REST endpoints. Gitea also ships with a built-in git repository health checker and automatic mirror synchronization, which reduces the manual work of keeping forks in sync with upstream repositories.
Forgejo's admin panel is identical to Gitea's for core operations — they share the same codebase at the admin layer. The federation-specific administration (managing federated repository follows, reviewing incoming ActivityPub messages) is handled through additional admin pages added by the Forgejo team. For teams that need visibility into federated interactions, Forgejo gives you more than any other option. Forgejo's releases are more conservatively managed than Gitea's, with a stronger emphasis on backporting security fixes to older release series — a meaningful difference for teams that don't want to track every minor version. Forgejo's slower release cadence also means less churn in configuration and API behavior between upgrades.
Gogs provides the least administrative surface, which is a feature rather than a limitation. If your only requirement is "run a Git server and not think about it," Gogs can run for months on a $5 VPS without any maintenance. The admin panel handles user management and repository visibility; it does not have background job management, health checks, or a complete admin API. When something does need attention — upgrading, migrating to a larger server, enabling email — the Gogs documentation and community are smaller and less current than Gitea's.
Migrating Between Platforms
When evaluating self-hosted Git options, understanding the migration path in both directions matters — especially if you're starting with the simpler option and expect to need more features later.
Migrating from Gogs to Gitea is well-supported and officially documented. Gitea can read a Gogs database directly: run the Gitea binary against the same database and it performs an automatic schema migration. The repository data (bare git repos on disk) doesn't move at all. This migration path has been tested by thousands of teams and typically completes in under 10 minutes for small to medium installations.
Migrating from Gitea to Forgejo is similarly straightforward. Since Forgejo is a soft fork with compatible database schemas, the migration is: stop Gitea, swap the container image to the Forgejo equivalent, restart — the database migrates automatically on first boot. Federation features require explicit configuration after migration, but all existing repositories, users, and permissions carry over without manual intervention.
Migrating outward to GitHub or GitLab from any of these platforms is less smooth. Gitea has a GitHub importer and can push mirror repositories to GitHub, but issue history, pull request data, and wiki pages require custom export scripts. For teams considering a future migration to GitHub Enterprise or GitLab, keeping your self-hosted Git as a mirror of a GitHub/GitLab source of truth — rather than the source of record — is the easier long-term strategy.
Security Model and Authentication
All three platforms support SSH key management, HTTPS access, and LDAP/Active Directory authentication out of the box. The differences appear in more advanced authentication scenarios and in how each platform handles the security of the CI/CD layer.
Gitea and Forgejo both support OAuth2 as a provider — meaning you can use Gitea or Forgejo as the OAuth server for other internal tools. This matters for teams building a self-hosted DevOps platform: you can authenticate your package registry, internal documentation, and CI dashboard against the same Gitea OAuth server without running a separate identity provider. SAML support requires a reverse proxy (Authelia, Keycloak) for all three platforms; none implements SAML natively.
Gitea Actions secrets management is worth highlighting specifically. When you use Gitea Actions for CI/CD, secret values (API keys, deployment credentials) are stored encrypted in the Gitea database and injected as environment variables into runner jobs. This is a complete secrets management system that Gogs completely lacks — with Gogs, secrets for CI pipelines must be managed externally. For teams that need CI/CD without building a separate secrets management layer, this is a decisive capability gap.
Gogs provides only basic token authentication and does not function as an OAuth provider. For organizations with strict security requirements — signed commits, commit signature verification in pull requests, or secrets injection for CI workflows — Gogs does not have these capabilities and should not be chosen when they are requirements. Teams that start with Gogs and later need these features typically migrate to Gitea, which is why understanding the migration path described above is worth doing before the initial deployment rather than after.
Methodology
GitHub/Codeberg stars as of March 2026. Feature comparison based on Gitea v1.22.x, Forgejo v9.x, and Gogs v0.13.x.
Compare developer tools and DevOps platforms on PkgPulse →
See also: AVA vs Jest and Pulumi vs SST vs CDKTF 2026, Coolify vs CapRover vs Dokku (2026).