GitHub dominates the Git hosting landscape, but depending on a single centralized platform for your source code comes with risks: policy changes, outages, pricing adjustments, and the fundamental loss of control over your most valuable asset. Self-hosting a Git server gives you full ownership of your repositories, eliminates vendor lock-in, and allows integration with your internal infrastructure on your own terms.

Three open-source platforms stand out for self-hosted Git: Gitea (lightweight and fast), GitLab CE (full-featured DevOps platform), and Forgejo (community-governed Gitea fork). Each makes different trade-offs between resource usage, features, and philosophy. This guide compares them in depth and shows how to deploy each with Docker.

The Three Contenders

Gitea: Lightweight and Fast

Gitea is a single Go binary that provides a GitHub-like experience with minimal resource overhead. It was forked from Gogs in 2016 and has grown into a mature, feature-rich platform. Gitea focuses on being lightweight, easy to install, and easy to maintain.

GitLab CE: The Full DevOps Platform

GitLab Community Edition is far more than a Git server. It is an entire DevOps lifecycle platform with built-in CI/CD, container registry, package registry, security scanning, issue tracking, and project management. The trade-off is significant resource requirements.

Forgejo: Community-First Fork

Forgejo forked from Gitea in late 2022 after governance concerns when Gitea Ltd. was formed as a for-profit company. Forgejo is maintained by Codeberg e.V. (a non-profit) and aims to keep the project purely community-governed. It is API-compatible with Gitea and shares most of its codebase, with some diverging features.

Feature Comparison

Feature Gitea GitLab CE Forgejo
Language Go Ruby / Go Go
Minimum RAM 256 MB 4 GB (8 GB recommended) 256 MB
Idle RAM usage ~150 MB ~2.5 GB ~150 MB
Built-in CI/CD Gitea Actions (GitHub Actions compatible) GitLab CI (mature, powerful) Forgejo Actions
Container registry Yes (packages) Yes (full registry) Yes (packages)
Package registry npm, PyPI, Maven, NuGet, etc. Comprehensive npm, PyPI, Maven, NuGet, etc.
Issue tracking Yes (labels, milestones, projects) Yes (boards, epics, weights) Yes (labels, milestones, projects)
Wiki Yes (per-repo) Yes (per-repo + group wikis) Yes (per-repo)
Code review Pull requests with review Merge requests with approval rules Pull requests with review
LDAP/OAuth Yes Yes (extensive) Yes
Migration tools GitHub, GitLab, Bitbucket importers GitHub, Bitbucket importers GitHub, GitLab importers
API Swagger REST API Comprehensive REST + GraphQL Swagger REST API
License MIT MIT (CE) GPL-3.0

Docker Deployment: Gitea

Gitea is the easiest to deploy and the lightest on resources:

# docker-compose.yml for Gitea
version: "3.8"
services:
  gitea:
    image: gitea/gitea:latest
    container_name: gitea
    restart: unless-stopped
    environment:
      - USER_UID=1000
      - USER_GID=1000
      - GITEA__database__DB_TYPE=postgres
      - GITEA__database__HOST=gitea-db:5432
      - GITEA__database__NAME=gitea
      - GITEA__database__USER=gitea
      - GITEA__database__PASSWD=${DB_PASSWORD}
      - GITEA__server__ROOT_URL=https://git.example.com/
      - GITEA__server__SSH_DOMAIN=git.example.com
      - GITEA__server__SSH_PORT=2222
      - GITEA__mailer__ENABLED=true
      - [email protected]
      - GITEA__service__DISABLE_REGISTRATION=true
    volumes:
      - gitea_data:/data
      - /etc/timezone:/etc/timezone:ro
      - /etc/localtime:/etc/localtime:ro
    ports:
      - "3000:3000"
      - "2222:22"
    depends_on:
      - gitea-db

  gitea-db:
    image: postgres:16-alpine
    container_name: gitea-db
    restart: unless-stopped
    environment:
      - POSTGRES_DB=gitea
      - POSTGRES_USER=gitea
      - POSTGRES_PASSWORD=${DB_PASSWORD}
    volumes:
      - gitea_db:/var/lib/postgresql/data

volumes:
  gitea_data:
  gitea_db:

Gitea Actions (CI/CD) requires a separate runner:

# Add to docker-compose.yml for CI/CD
  gitea-runner:
    image: gitea/act_runner:latest
    container_name: gitea-runner
    restart: unless-stopped
    environment:
      - GITEA_INSTANCE_URL=http://gitea:3000
      - GITEA_RUNNER_REGISTRATION_TOKEN=${RUNNER_TOKEN}
      - GITEA_RUNNER_NAME=default-runner
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - gitea_runner_data:/data
    depends_on:
      - gitea

volumes:
  gitea_runner_data:

Docker Deployment: GitLab CE

GitLab CE requires significantly more resources but provides an all-in-one DevOps platform:

# docker-compose.yml for GitLab CE
version: "3.8"
services:
  gitlab:
    image: gitlab/gitlab-ce:latest
    container_name: gitlab
    restart: unless-stopped
    hostname: gitlab.example.com
    environment:
      GITLAB_OMNIBUS_CONFIG: |
        external_url 'https://gitlab.example.com'
        gitlab_rails['gitlab_shell_ssh_port'] = 2222

        # PostgreSQL (use built-in or external)
        # postgresql['enable'] = true

        # Redis
        # redis['enable'] = true

        # Email configuration
        gitlab_rails['smtp_enable'] = true
        gitlab_rails['smtp_address'] = "smtp.example.com"
        gitlab_rails['smtp_port'] = 587

        # Container registry
        registry_external_url 'https://registry.example.com'

        # Monitoring
        prometheus_monitoring['enable'] = true

        # Reduce memory usage (optional)
        puma['worker_processes'] = 2
        sidekiq['max_concurrency'] = 10
        prometheus_monitoring['enable'] = false
        grafana['enable'] = false
    ports:
      - "80:80"
      - "443:443"
      - "2222:22"
    volumes:
      - gitlab_config:/etc/gitlab
      - gitlab_logs:/var/log/gitlab
      - gitlab_data:/var/opt/gitlab
    shm_size: '256m'

volumes:
  gitlab_config:
  gitlab_logs:
  gitlab_data:
Warning: GitLab CE will consume 2-4 GB of RAM even at idle. On a server with less than 8 GB of RAM, consider disabling Prometheus, Grafana, and reducing Puma workers. For small teams, Gitea or Forgejo may be a better fit.

Docker Deployment: Forgejo

Forgejo is a drop-in replacement for Gitea with the same lightweight footprint:

# docker-compose.yml for Forgejo
version: "3.8"
services:
  forgejo:
    image: codeberg.org/forgejo/forgejo:latest
    container_name: forgejo
    restart: unless-stopped
    environment:
      - USER_UID=1000
      - USER_GID=1000
      - FORGEJO__database__DB_TYPE=postgres
      - FORGEJO__database__HOST=forgejo-db:5432
      - FORGEJO__database__NAME=forgejo
      - FORGEJO__database__USER=forgejo
      - FORGEJO__database__PASSWD=${DB_PASSWORD}
      - FORGEJO__server__ROOT_URL=https://code.example.com/
      - FORGEJO__server__SSH_DOMAIN=code.example.com
      - FORGEJO__server__SSH_PORT=2222
    volumes:
      - forgejo_data:/data
      - /etc/timezone:/etc/timezone:ro
      - /etc/localtime:/etc/localtime:ro
    ports:
      - "3000:3000"
      - "2222:22"
    depends_on:
      - forgejo-db

  forgejo-db:
    image: postgres:16-alpine
    container_name: forgejo-db
    restart: unless-stopped
    environment:
      - POSTGRES_DB=forgejo
      - POSTGRES_USER=forgejo
      - POSTGRES_PASSWORD=${DB_PASSWORD}
    volumes:
      - forgejo_db:/var/lib/postgresql/data

volumes:
  forgejo_data:
  forgejo_db:

Resource Usage Comparison

Real-world resource usage with 50 repositories and 5 active users:

Metric Gitea GitLab CE Forgejo
Idle RAM ~150 MB ~2.5 GB ~150 MB
Active RAM (CI running) ~300 MB + runner ~4 GB ~300 MB + runner
Disk (base install) ~200 MB ~3 GB ~200 MB
Startup time ~3 seconds ~60-120 seconds ~3 seconds
Docker image size ~110 MB ~2.8 GB ~110 MB

CI/CD Capabilities

Gitea Actions / Forgejo Actions

Both use a GitHub Actions-compatible workflow syntax, making migration straightforward:

# .gitea/workflows/build.yml (or .forgejo/workflows/build.yml)
name: Build and Test
on:
  push:
    branches: [main]
  pull_request:

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with:
          go-version: '1.22'
      - run: go build ./...
      - run: go test -race ./...

GitLab CI

GitLab CI uses its own syntax, which is more powerful but requires learning a different DSL:

# .gitlab-ci.yml
stages:
  - build
  - test
  - deploy

build:
  stage: build
  image: golang:1.22
  script:
    - go build ./...
  artifacts:
    paths:
      - binary

test:
  stage: test
  image: golang:1.22
  script:
    - go test -race -cover ./...
  coverage: '/coverage: \d+.\d+%/'

deploy:
  stage: deploy
  script:
    - docker build -t registry.example.com/myapp:$CI_COMMIT_SHA .
    - docker push registry.example.com/myapp:$CI_COMMIT_SHA
  only:
    - main

Migration from GitHub

All three platforms support importing repositories from GitHub, including issues, pull requests, labels, and milestones.

Gitea/Forgejo Migration

# Via the web UI:
# New Migration > GitHub > Enter repository URL and token

# Via API:
curl -X POST "https://git.example.com/api/v1/repos/migrate" \
  -H "Authorization: token YOUR_GITEA_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "clone_addr": "https://github.com/org/repo.git",
    "auth_token": "ghp_YOUR_GITHUB_TOKEN",
    "repo_name": "repo",
    "repo_owner": "myorg",
    "service": "github",
    "mirror": false,
    "issues": true,
    "labels": true,
    "milestones": true,
    "pull_requests": true,
    "releases": true
  }'

Bulk Migration Script

#!/bin/bash
# migrate-github-org.sh - Migrate all repos from a GitHub org
GITHUB_ORG="my-org"
GITHUB_TOKEN="ghp_xxx"
GITEA_URL="https://git.example.com"
GITEA_TOKEN="xxx"
GITEA_ORG="my-org"

# Get all GitHub repos
repos=$(curl -s -H "Authorization: token $GITHUB_TOKEN" \
  "https://api.github.com/orgs/$GITHUB_ORG/repos?per_page=100" | \
  jq -r '.[].full_name')

for repo in $repos; do
  repo_name=$(basename "$repo")
  echo "Migrating: $repo -> $GITEA_ORG/$repo_name"

  curl -s -X POST "$GITEA_URL/api/v1/repos/migrate" \
    -H "Authorization: token $GITEA_TOKEN" \
    -H "Content-Type: application/json" \
    -d "{
      \"clone_addr\": \"https://github.com/$repo.git\",
      \"auth_token\": \"$GITHUB_TOKEN\",
      \"repo_name\": \"$repo_name\",
      \"repo_owner\": \"$GITEA_ORG\",
      \"service\": \"github\",
      \"mirror\": false,
      \"issues\": true,
      \"labels\": true,
      \"pull_requests\": true,
      \"releases\": true
    }"

  sleep 2  # Rate limiting
done
Tip: Set up GitHub mirrors first before doing a full migration. This lets you verify everything works while keeping GitHub as the source of truth during the transition period. Once verified, switch the mirror to one-way push from your self-hosted instance back to GitHub (or disable mirroring entirely).

Which One Should You Choose?

  • Choose Gitea if you want a lightweight, fast Git server with good CI/CD support and GitHub-compatible workflows. Best for small to medium teams with limited server resources.
  • Choose GitLab CE if you need a full DevOps platform with mature CI/CD, container registry, security scanning, and project management. Best for larger teams willing to dedicate 8+ GB RAM.
  • Choose Forgejo if you want Gitea's lightweight approach with a commitment to community governance and open-source principles. Best for those who want to avoid any commercial influence in their tooling.

For most self-hosters managing a handful of projects, Gitea or Forgejo provides 90% of what you need at 10% of GitLab's resource cost. GitLab CE is worth the overhead only if you actively use its advanced CI/CD features, security scanning, or project management capabilities.

Whichever platform you choose, managing it alongside your other Docker services is straightforward with container management tools like usulnet, which provides visibility into resource usage, container health, and backup status across your entire self-hosted stack.