Gitea vs Forgejo vs GitLab: RAM and git speed
Tested on Gitea 28.3.0
Test result
For a small self-hosted forge, Gitea (94.5 MiB) and Forgejo (105.6 MiB) cost about the same and pushed the same repo in the same time. GitLab CE needed an 8 GB VM, used 4,217.3 MiB before any repository existed and 172 s to become ready; its push (median 2.50 s) matched the other two, its clone was slower (0.59 s against 0.40 and 0.36), and four of its five API reads were slower than both light forges (project info 100.6 ms against 24.7 and 4.5), with the commit list the only tie.

The result in one line: if you only need repositories, pull requests and an API for a few people, the two light forges are interchangeable on cost, and GitLab is a different class of server that wants a machine of its own.
One repository, three servers
Each server ran in its own fresh VM, configured and with an admin account (not left on the installer page). The script created three repositories through each API, pushed one generated repository to each of them, and cloned the first one over HTTP three times. The generated repository has 300 commits on main. Each commit adds a text file of about 5.5 KiB and edits an earlier one, and every 15th commit also adds a 1 MiB file of random bytes, 20 in all. Git reported 1,539 loose objects and 26.54 MiB before the first push (raw/gitea-repo.txt, the same generator and seed for all three).
| Server | Seconds from `up -d` to ready | RAM after setup, before any push (MiB, median of 12) | RAM right after the benchmark (MiB) | API create repo (s) | Push of the generated repo (s) | Clone over HTTP (s) | Data volume after benchmark (MB) | Commits in the clone |
|---|---|---|---|---|---|---|---|---|
| Gitea 28.3.0 | 15 | 94.5 | 124.4 | 0.12 (0.12-0.18) | 2.63 (2.62-2.73) | 0.40 (0.39-0.40) | 67.7 | 300 |
| Forgejo 17.0.1 | 2 | 105.6 | 149.6 | 0.05 (0.02-0.05) | 2.56 (2.44-2.60) | 0.36 (0.36-0.37) | 72.0 | 300 |
| GitLab CE 19.4.5 (8 GB VM) | 172 | 4217.3 | 4983.3 | 0.54 (0.50-1.77) | 2.50 (2.44-3.56) | 0.59 (0.54-0.67) | 557.2 | 300 |
Installer skipped (INSTALL_LOCK) and an admin created, so RAM is for a configured server, unlike the installer-page figures in the idle stack measurements. Generated repo: 300 commits, 20 random 1 MiB files; see repo.txt. Loopback inside one VM (4 vCPU), so no network cost. Data volume for GitLab is /var/opt/gitlab only.
GitLab ran in a VM with LAB_MEM=8192; the other two used the standard 3 GB VM. That is deliberate and it is a result: the GitLab requirements page says GitLab can run in a memory-constrained single node with at least 8 GB (16 GB is its stated baseline for a single node), and in this run it held 4,217.3 MiB before the first push and 4,983.3 MiB after the clone and API calls. The test did not try GitLab in 3 GB, so it does not show whether it fails there.
Gitea and Forgejo became ready in 15 s and 2 s, and GitLab in 172 s (readiness polled once a second over SSH, so each figure is rounded to whole seconds). The difference between Gitea and Forgejo was not investigated; both numbers include first-start database set-up, so do not read them as a startup ranking from one run each.
The API
| Call | Gitea 28.3.0 | Forgejo 17.0.1 | GitLab CE 19.4.5 (8 GB VM) |
|---|---|---|---|
| project info | 24.7 (19.4-26.8) | 4.5 (4.2-9.1) | 100.6 (66.4-542.7) |
| commit list (30) | 120.4 (70.3-132.5) | 74.7 (62.3-122.7) | 74.6 (53.4-133.5) |
| tree | 26.9 (21.1-28.9) | 15.4 (10.2-19.5) | 60.5 (49.5-115.5) |
| file contents | 29.4 (24.7-65.2) | 40.7 (34.7-42.5) | 82.4 (53.0-169.8) |
| branches | 26.6 (24.6-31.4) | 15.5 (8.5-18.7) | 56.4 (34.4-148.9) |
Same repo, same call shape; endpoints differ per product (Gitea and Forgejo share the /api/v1 API, GitLab uses /api/v4).
Twenty sequential reads per call over loopback. Forgejo had the lowest median on three of five calls (project info 4.5 ms, tree 15.4, branches 15.5 against Gitea’s 24.7, 26.9 and 26.6). Gitea had the lowest on file contents (29.4 ms against Forgejo’s 40.7, with overlapping ranges). On the commit list the three medians were 120.4 (Gitea), 74.7 (Forgejo) and 74.6 ms (GitLab), and the ranges overlap, so that is a tie between Forgejo and GitLab and at most a small edge over Gitea. On the other four calls GitLab’s medians were higher than both light forges (project info 100.6 ms, tree 60.5, file contents 82.4, branches 56.4), though GitLab’s file-contents range (53.0 to 169.8 ms) overlaps Gitea’s and Forgejo’s. GitLab’s project info had a worst case of 542.7 ms, which looks like first-request cost, but the test did not separate warm and cold calls. The calls are not identical work: the three products’ endpoints return different fields, so these are latencies of comparable calls, not of identical ones. Creating a repository through the API took a median of 0.12 s on Gitea, 0.05 s on Forgejo and 0.54 s on GitLab (three each; GitLab’s range was 0.50 to 1.77 s).
The push and clone figures are over loopback inside one VM, so they say nothing about network speed. Each server was run once. Push medians were 2.63 s (Gitea), 2.56 s (Forgejo) and 2.50 s (GitLab), inside each other’s ranges (GitLab’s worst push was 3.56 s). Clone medians were 0.40, 0.36 and 0.59 s: GitLab’s three clones (0.54 to 0.67 s) were all slower than the other two’s (0.36 to 0.40 s), by about 0.2 s. That gap comes from three clones in one run on a VM with more RAM than the others, so it is a lead, not a rule.



Two measurements of the same server
The lab’s separate idle measurements of Gitea and Forgejo were taken at the installer page, before setup. Setup changes the picture, so here are both states, labelled:
| Stack | Version | RAM at idle | CPU at idle | Images on disk | Ready after |
|---|---|---|---|---|---|
| Gitea | 28.3.0 | 0.00% | 261.7 MB | 2 s | |
| Forgejo | 17.0.1 | 0.02% | 291.2 MB | 1.8 s |
Those rows are the installer state. After setup (INSTALL_LOCK and an admin user) the same images measured 94.5 MiB for Gitea and 105.6 MiB for Forgejo, and 124.4 and 149.6 MiB right after the benchmark. For Gitea that is a jump of 33.0 MiB from the installer-page figure; Forgejo’s two states were within about 3.5 MiB. Use the configured numbers. For GitLab the lab’s idle measurement (4,428.3 MiB, ready after 168.1 s) is in the same range as the configured 4,217.3 MiB.
Which one
This part is opinion, based on one box and one small repository. Forgejo and Gitea were close enough in these numbers that the choice is not about speed or RAM. Licensing is a separate question: Forgejo’s FAQ says versions from v9.0 on are GPL v3 or later, and earlier ones MIT. GitLab is the option to pick for its built-in CI, issue boards and other features, and this test did not use any of them. It also did not measure what GitLab costs once those features are used.
Steps
The compose files are the lab’s stack files with the installer skipped. For Gitea, GITEA__security__INSTALL_LOCK=true sets INSTALL_LOCK (docs); Forgejo is set the same way with its own prefix. GitLab gets a fixed initial root password.
Show the full file
# lab/stacks/gitea/compose.yml plus INSTALL_LOCK, so the installer page is skipped and the server runs configured.
services:
gitea:
image: gitea/gitea:28.3.0
restart: unless-stopped
environment:
USER_UID: "1000"
USER_GID: "1000"
GITEA__security__INSTALL_LOCK: "true"
GITEA__server__ROOT_URL: http://127.0.0.1:8080/
ports:
- "127.0.0.1:8080:3000"
volumes:
- gitea_data:/data
volumes:
gitea_data:This file uses the public test value lab-only-not-a-secret. Replace every instance with your own secret before you run it.
Show the full file
# lab/stacks/gitlab/compose.yml plus a fixed initial root password.
services:
gitlab:
image: gitlab/gitlab-ce:19.4.5-ce.0
restart: unless-stopped
shm_size: 256m
hostname: gitlab.lab.internal
environment:
GITLAB_OMNIBUS_CONFIG: "gitlab_rails['initial_root_password'] = 'lab-only-not-a-secret'"
ports:
- "127.0.0.1:8080:80"
volumes:
- gitlab_config:/etc/gitlab
- gitlab_logs:/var/log/gitlab
- gitlab_data:/var/opt/gitlab
volumes:
gitlab_config:
gitlab_logs:
gitlab_data:This file uses the public test value lab-only-not-a-secret. Replace every instance with your own secret before you run it.
Show the full file
#!/usr/bin/env bash
# git-bench: Gitea 28.3.0, Forgejo 17.0.1 and GitLab CE 19.4.5, each in its own fresh VM, configured (installer skipped).
# Creates repos through the API, pushes a generated 300-commit repo, clones it 3 times, times 5 API reads (20 calls each).
# Usage: run.sh [gitea] [forgejo] [gitlab] (default: all three; GitLab runs alone with LAB_MEM=8192)
set -uo pipefail
source "$(dirname "$0")/../_dlib.sh"
T=git-bench; OUT="$ROOT/evidence/tests/$T"; RAW="$OUT/raw"; D="$ROOT/lab/tests/$T"
TOOLS=("$@"); [ ${#TOOLS[@]} -eq 0 ] && TOOLS=(gitea forgejo gitlab)
mkdir -p "$RAW"
one() { # one <tool>: runs in a subshell so the VM trap fires at its end
local tool="$1"
[ "$tool" = gitlab ] && export LAB_MEM=8192
start_vm d-$T-$tool
gx 'mkdir -p ~/gb' ; vm scp-to "$D/files/$tool.yml" gb/compose.yml >/dev/null
vm scp-to "$D/genrepo.py" genrepo.py >/dev/null; vm scp-to "$D/bench.py" bench.py >/dev/null; vm scp-to "$ROOT/lab/tests/_dsample.py" sample.py >/dev/null
cpull '~/gb' gb
gx 'python3 ~/genrepo.py /tmp/repo && cd /tmp/repo && git rev-list --count HEAD && git count-objects -vH && du -sb /tmp/repo/.git /tmp/repo --exclude=.git' > "$RAW/$tool-repo.txt" 2>&1
local t0; t0=$(date +%s)
gx 'cd ~/gb && docker compose -p gb up -d 2>&1 | tail -1'
local base=http://127.0.0.1:8080 user=lab token
case $tool in
gitea|forgejo)
until gx "curl -sf $base/api/healthz >/dev/null"; do sleep 1; done; echo "ready_s=$(( $(date +%s) - t0 ))" > "$RAW/$tool-ready.txt"
gx "docker exec -u git gb-$tool-1 $tool admin user create --admin --username lab --password lab-only-not-a-secret --email lab@lab.internal --must-change-password=false" > "$RAW/$tool-admin.txt" 2>&1
token=$(gx "curl -s -u lab:lab-only-not-a-secret -H 'content-type: application/json' -d '{\"name\":\"bench\",\"scopes\":[\"all\"]}' $base/api/v1/users/lab/tokens" | python3 -c "import json,sys;print(json.load(sys.stdin)['sha1'])") ;;
gitlab)
until [ "$(gx 'docker inspect -f {{.State.Health.Status}} gb-gitlab-1' 2>/dev/null)" = healthy ] && gx "curl -s -o /dev/null -w '%{http_code}' $base/users/sign_in | grep -q 200"; do sleep 10; [ $(( $(date +%s) - t0 )) -gt 1500 ] && { echo "gitlab not ready in 1500 s" > "$RAW/gitlab-FAILED.txt"; return 1; }; done
echo "ready_s=$(( $(date +%s) - t0 ))" > "$RAW/gitlab-ready.txt"; user=root
token=lab-bench-token-0123456789
gx "docker exec gb-gitlab-1 gitlab-rails runner \"u=User.find_by_username('root'); t=u.personal_access_tokens.create!(name: 'bench', scopes: [:api, :write_repository, :read_repository], expires_at: 30.days.from_now); t.set_token('$token'); t.save!; puts 'token ok'\"" > "$RAW/gitlab-token.txt" 2>&1 ;;
esac
sleep 120 # settle before the "after setup" RAM sample
gx "python3 ~/sample.py gb-$tool-1" > "$RAW/$tool-ram-after-setup.json"
gx "python3 ~/bench.py $([ $tool = gitlab ] && echo gitlab || echo gitea) $base $user $token /tmp/repo" > "$RAW/$tool-bench.json" 2> "$RAW/$tool-bench.err"
gx "python3 ~/sample.py gb-$tool-1" > "$RAW/$tool-ram-after-bench.json"
gx "sudo sh -c \"du -sb /var/lib/docker/volumes/gb_*/_data\" | sort -k2; docker inspect -f '{{.Name}} restarts={{.RestartCount}}' \$(docker ps -aq)" > "$RAW/$tool-disk.txt" 2>&1
local S="$ROOT/public/shots/$T"
if [ $tool = gitlab ]; then
shot 8080 /root/bench-1 "$S/$tool.png" '[{"goto":"http://127.0.0.1:18391/users/sign_in"},{"fill":"#user_login","value":"root"},{"fill":"#user_password","value":"lab-only-not-a-secret"},{"press":"Enter"},{"wait":6000},{"goto":"http://127.0.0.1:18391/root/bench-1"}]'
else
shot 8080 /user/login "$S/$tool.png" '[{"fill":"#user_name","value":"lab"},{"fill":"#password","value":"lab-only-not-a-secret"},{"press":"Enter"},{"wait":3000},{"goto":"http://127.0.0.1:18391/lab/bench-1"},{"wait":3000}]'
fi
mkdir -p "$RAW/$tool-env"; collect_env "$RAW/$tool-env"
gx "docker image inspect --format '{{index .RepoDigests 0}}' \$(docker compose -f ~/gb/compose.yml config --images)" > "$RAW/$tool-image.txt" 2>&1
}
for t in "${TOOLS[@]}"; do ( one "$t" ); done
for t in "${TOOLS[@]}"; do [ -f "$RAW/$t-bench.json" ] || echo "$t: no bench result"; done
cp "$D/files/"*.yml "$OUT/" 2>/dev/null
python3 "$D/summarize.py" "$RAW" "$OUT"Show the full file
#!/usr/bin/env python3
"""Runs in the VM. Usage: bench.py <gitea|gitlab> <base_url> <user> <token> <repo_dir>. Prints JSON of timings (seconds)."""
import json, os, shutil, statistics as st, subprocess, sys, time, urllib.request
kind, base, user, token, repo = sys.argv[1:6]
def req(method, path, body=None):
h = {"content-type": "application/json"}
h.update({"PRIVATE-TOKEN": token} if kind == "gitlab" else {"Authorization": "token " + token})
t = time.perf_counter()
r = urllib.request.urlopen(urllib.request.Request(base + path, data=json.dumps(body).encode() if body is not None else None, headers=h, method=method), timeout=120)
data = r.read(); return time.perf_counter() - t, json.loads(data) if data else None
out = {"kind": kind}
# create three repositories through the API
create = []; ids = []
for i in (1, 2, 3):
if kind == "gitlab":
t, d = req("POST", "/api/v4/projects", {"name": f"bench-{i}", "visibility": "private", "initialize_with_readme": False}); ids.append(d["id"])
else:
t, d = req("POST", "/api/v1/user/repos", {"name": f"bench-{i}", "private": True, "default_branch": "main"})
create.append(round(t, 3))
out["api_create_repo_s"] = create
auth_host = base.replace("http://", f"http://{'oauth2' if kind == 'gitlab' else user}:{token}@")
ns = "root" if kind == "gitlab" else user
sh = lambda *a, cwd=None: subprocess.run(a, cwd=cwd, check=True, capture_output=True, text=True)
push = []
for i in (1, 2, 3):
t = time.perf_counter(); sh("git", "push", "-q", f"{auth_host}/{ns}/bench-{i}.git", "main", cwd=repo); push.append(round(time.perf_counter() - t, 2))
out["push_s"] = push
clone = []
for i in (1, 2, 3):
dst = f"/tmp/clone{i}"; shutil.rmtree(dst, ignore_errors=True)
t = time.perf_counter(); sh("git", "clone", "-q", f"{auth_host}/{ns}/bench-1.git", dst); clone.append(round(time.perf_counter() - t, 2))
out["clone_s"] = clone
out["clone_commits"] = int(sh("git", "rev-list", "--count", "HEAD", cwd="/tmp/clone3").stdout)
out["clone_checkout_bytes"] = int(subprocess.run("du -sb /tmp/clone3 | cut -f1", shell=True, capture_output=True, text=True).stdout)
if kind == "gitlab":
pid = ids[0]
calls = {"project info": f"/api/v4/projects/{pid}", "commit list (30)": f"/api/v4/projects/{pid}/repository/commits?per_page=30", "tree": f"/api/v4/projects/{pid}/repository/tree?per_page=100", "file contents": f"/api/v4/projects/{pid}/repository/files/src%2Fmod010.txt?ref=main", "branches": f"/api/v4/projects/{pid}/repository/branches"}
else:
calls = {"project info": f"/api/v1/repos/{user}/bench-1", "commit list (30)": f"/api/v1/repos/{user}/bench-1/commits?limit=30&stat=false", "tree": f"/api/v1/repos/{user}/bench-1/git/trees/main?per_page=100", "file contents": f"/api/v1/repos/{user}/bench-1/contents/src/mod010.txt?ref=main", "branches": f"/api/v1/repos/{user}/bench-1/branches"}
api = {}
for name, path in calls.items():
ts = []
for _ in range(20):
t, _d = req("GET", path); ts.append(t * 1000)
api[name] = {"median_ms": round(st.median(ts), 1), "min_ms": round(min(ts), 1), "max_ms": round(max(ts), 1)}
out["api_get_ms"] = api
print(json.dumps(out, indent=1))Show the full file
#!/usr/bin/env python3
"""Generates the benchmark repository: 300 commits on main. Each commit adds one ~5.5 KiB text file and edits one earlier file;
every 15th commit also adds a 1 MiB file of random bytes (20 in total, incompressible). Deterministic (seeded)."""
import os, random, subprocess, sys
d = sys.argv[1]; r = random.Random(42)
os.makedirs(d); sh = lambda *a: subprocess.run(a, cwd=d, check=True, capture_output=True)
sh("git", "init", "-q", "-b", "main"); sh("git", "config", "user.name", "Lab"); sh("git", "config", "user.email", "lab@lab.internal")
words = [w for w in "alpha bravo charlie delta echo foxtrot golf hotel india juliet kilo lima mike november oscar papa quebec romeo sierra tango uniform victor whiskey xray yankee zulu".split()]
texts = []
for i in range(300):
name = f"src/mod{i:03d}.txt"; os.makedirs(os.path.join(d, "src"), exist_ok=True)
open(os.path.join(d, name), "w").write(" ".join(r.choice(words) + str(r.randrange(1000)) for _ in range(600)) + "\n")
texts.append(name)
if i:
t = r.choice(texts[:-1]); open(os.path.join(d, t), "a").write(f"edit {i} " + " ".join(r.choice(words) for _ in range(20)) + "\n")
if i % 15 == 0:
os.makedirs(os.path.join(d, "blobs"), exist_ok=True); open(os.path.join(d, f"blobs/blob{i//15:02d}.bin"), "wb").write(r.randbytes(1 << 20))
sh("git", "add", "-A"); sh("git", "commit", "-q", "-m", f"commit {i}")Admin users were created from the command line, for example gitea admin user create --admin --username lab ... inside the container as the git user, and the API token with POST /api/v1/users/lab/tokens. For GitLab, the token came from gitlab-rails runner against the root user.
What went wrong
An earlier version of these runs had the guest drop its page cache every 15 s, which slowed disk-bound steps; it was removed and every test rerun. The numbers here come from reruns on 2026-11-02: Gitea at 09:09 to 09:16, Forgejo at 09:17 to 09:24 and GitLab at 09:35 to 09:44 UTC, with the host load average at the end of each run between 0.41 and 1.45. An earlier rerun (Gitea and Forgejo at 05:48 to 06:03, GitLab at 06:05 to 06:16 UTC) was made while the host was busy with other lab VMs (the host load average was above 9 for the first two) and was replaced, so all three were measured on a quiet host. The replaced run showed slower pushes for Gitea and Forgejo, so host load visibly moves these numbers. Nothing failed in the final runs. GitLab was ready when its container reported healthy and the sign-in page answered.
What this test did not cover
Sources and how this was made
- Gitea docs: Configuration cheat sheet (INSTALL_LOCK)
- Forgejo FAQ: licensing
- GitLab docs: Installation requirements
How this page was made: the commands above were run by an AI agent on the test box on November 2, 2026, and the same agent drafted this text from the saved output. The site's owner reviewed the site before publication. Test method and editorial policy explain how results are checked, and what is not. Spotted an error? Tell us.