Skip to main content

Gitea vs Forgejo vs Gogs: Self-Hosted Git Platforms (2026)

·PkgPulse Team

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

FeatureGiteaForgejoGogs
LanguageGoGoGo
OriginGogs 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 reviewBasic
Wiki
Issue tracker
Webhooks
OAuth2 provider
LDAP/AD
Mirror repos
LFS support
Resource usageLow (~200MB RAM)Low (~200MB RAM)Minimal (~50MB RAM)
SQLite support
GovernanceCompany (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.

Compare developer tools and DevOps platforms on PkgPulse →

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.