
Delivery
Gitea vs GitLab: How Much Forge Do You Want to Operate?
Gitea is a forge; GitLab is a platform. Compare footprint, upgrade stops, backup secrets, audit tiers and migration paths before you self-host either.
Self-hosting a forge is easy to start, and most comparisons stop there: a feature grid, a RAM figure of uncertain origin, a verdict. What decides the choice arrives later, when the instance is two quarters behind and the backup has never been restored. This article works through that operating burden from each project's own documentation.
Versions are as of October 2026: Gitea 28.1.0, and GitLab 19.5 as the current release on GitLab's maintenance page, with 19.4 and 19.3 receiving security fixes.
The short answer: who is on call
Choose Gitea if you want a forge your team can upgrade without a project plan: replace a binary, migrate a schema, done. Choose GitLab if you want the platform (issues, merge requests, CI, packages, compliance tooling in one product) and you will staff its upgrade train: monthly minors, required stops, and a security-fix window that does not wait for you.
Neither is better in the abstract. The honest question is which one you will still be running correctly in three years.
A forge and a platform are different shapes of software
Gitea is a single Go binary in front of a database. Its database guide lists PostgreSQL (12 or later), MySQL (8.0 or later), MariaDB (10.4 or later), a built-in SQLite and MSSQL (2012 SP4 or later). Repositories, configuration in app.ini, and data files sit on disk beside it. CI is optional: Gitea Actions runs jobs on Gitea Runner, which its overview describes as a standalone program, also written in Go.
GitLab is a platform. The Linux package, still widely called Omnibus, "bundles all the different services and tools required to run GitLab" into one install. GitLab's architecture page describes the request path: NGINX proxies through GitLab Workhorse into the Puma application server, which serves the web pages and API, while Sidekiq runs the job queue and uses Redis as "a non-persistent database backend for job information, metadata, and incoming jobs." Gitaly serves repositories, PostgreSQL holds the application state, and bundled Prometheus and exporters watch it all.
Each component is a process to size, a version to keep compatible and a state store to back up. One package hides that count at install time, not at upgrade time.
Footprint: what the vendors themselves say
Published comparisons quote hardware figures that disagree wildly for the same product. Go to the source. GitLab's requirements page sets the single-node baseline at 8 vCPU and 16 GB of memory, says GitLab "can run with at least 8 GB of memory" in memory-constrained environments, and tells you to disable swap where possible, or otherwise to "provision sufficient memory so GitLab never uses it."
There is a smaller figure, and it comes with conditions. GitLab's memory-constrained guide gives a minimum of 2 GB of RAM plus 1 GB of swap, but scopes it to "up to 5 developers with individual Git repositories no larger than 100 MB", and warns that with those settings "the system should use swap during regular operation." That is a different operating point, reached by switching parts of the platform off:
# /etc/gitlab/gitlab.rb. Bundled monitoring off (GitLab observed ~300 MB):
alertmanager['enable'] = false
gitlab_exporter['enable'] = false
gitlab_kas['enable'] = false
node_exporter['enable'] = false
postgres_exporter['enable'] = false
prometheus_monitoring['enable'] = false
prometheus['enable'] = false
puma['exporter_enabled'] = false
redis_exporter['enable'] = false
sidekiq['metrics_enabled'] = false
# Single Puma process, not clustered mode (GitLab observed 100-400 MB):
puma['worker_processes'] = 0
sidekiq['concurrency'] = 10Gitea's documentation states that 2 CPU cores and 1 GB of RAM is typically sufficient for small teams and projects, and that a Raspberry Pi 3 can run it for small workloads. Both are vendor-stated baselines under different load assumptions, not a benchmark, so no ratio between them means anything. The structural point stands: GitLab's small-instance guide is a list of things to disable; Gitea's whole deployment is two services.
Feature surface you will actually use
Both cover repositories, review, issues and CI. The differences worth checking are narrower, and the best list is Gitea's own comparison table, which carries an honest caveat: "we don't regularly check for feature changes in other products, so this list may be outdated." Confirm anything decisive against GitLab's current docs.
- Subgroups. Gitea's table marks groups within groups as unsupported in Gitea and supported in GitLab CE. Test this first if teams map to nested namespaces.
- Group milestones. Marked unsupported in Gitea and supported in GitLab CE.
- Confidential issues. Same marking; relevant where security reports share a tracker.
- CI model. Gitea Actions has been built in since Gitea 1.19 and is described as "similar and mostly compatible to GitHub Actions". GitLab CI uses its own pipeline syntax.
- Package publishing from CI. Gitea's Actions comparison notes that
GITEA_TOKENcannot publish to its own repository's package registry, for example to push OCI images; a personal access token is required instead. That is one more credential to scope and rotate.
If none of those bite, the decision moves to operations. If you need GitHub-Actions-shaped CI on your own hardware, the Forgejo, Woodpecker and Zot stack and self-hosted runners on Kubernetes cover the compute side; where images land is a registry decision either way.
The upgrade train: GitLab's stops versus Gitea's one-way migrations
GitLab's maintenance policy is a fixed cadence: a minor release monthly on the third Thursday, patch releases twice a month, and a major release yearly, with GitLab 20.0 scheduled for May 20, 2027. Bug fixes are backported only to the current stable release; security fixes go to the current release and the previous two monthly releases. That is the default policy, not a guarantee: the same page allows that some vulnerabilities are fixed only in the current stable release.
The upgrade-paths page adds required stops. From GitLab 17.5 onward they fall at x.2.z, x.5.z, x.8.z and x.11.z, so GitLab 19 lists 19.2, 19.5, 19.8 and 19.11, of which 19.8 and 19.11 are documented future stops, not releases you can install in October 2026. Each stop means the latest patch of that minor (the page's example is 16.8.7 rather than 16.8.0), and the background migrations for one stop must finish before you upgrade to the next. An instance still on GitLab 18 passes 18.2, 18.5, 18.8 and 18.11 first. GitLab 17's table has the same pattern from 17.5.5 plus an earlier 17.3.7 stop and a conditional 17.1.8; anything on 16 or older follows its own major's table. Majors add their own gates: GitLab 19.0 made PostgreSQL 17 the minimum for all installation methods, after 18.x required 16.5.
Together, "we fell behind for a quarter" becomes more than one hop, each with a wait, starting outside the security window. A helper keeps the arithmetic honest:
#!/usr/bin/env bash
# gitlab19-stops.sh CURRENT TARGET, for example: gitlab19-stops.sh 19.1 19.5
# Stops from https://docs.gitlab.com/update/upgrade_paths/ (GitLab 19 table).
set -euo pipefail
STOPS=(2 5 8 11) # 19.2, 19.5, 19.8, 19.11
UNRELEASED=(8 11) # not released as of October 2026; recheck the page
if [[ $# -ne 2 ]]; then
echo "usage: $0 CURRENT TARGET (for example 19.1 19.5)" >&2
exit 2
fi
cur_major="${1%%.*}"
cur_minor="${1#*.}"
cur_minor="${cur_minor%%.*}"
tgt_major="${2%%.*}"
tgt_minor="${2#*.}"
tgt_minor="${tgt_minor%%.*}"
if ((cur_major < 19)); then
echo "This script covers GitLab 19 only. Below 19, follow your major's" \
"table on the upgrade-paths page: GitLab 17 stops 17.3.7, 17.5.5," \
"17.8.7, 17.11.7 (17.1.8 only for large ci_pipeline_messages tables)," \
"then 18.2, 18.5, 18.8, 18.11, latest patch each." >&2
exit 1
fi
if ((cur_major != 19 || tgt_major != 19 || tgt_minor <= cur_minor)); then
echo "Forward moves within GitLab 19 only." >&2
exit 1
fi
for s in "${STOPS[@]}"; do
if ((s == cur_minor)); then
echo "current 19.${s} is a stop: move to its latest 19.${s}.z patch first"
fi
if ((s > cur_minor && s <= tgt_minor)); then
note=""
for u in "${UNRELEASED[@]}"; do
if ((s == u)); then note=" (documented, not yet released)"; fi
done
echo "stop 19.${s}${note}: latest 19.${s}.z patch, not .0; then wait" \
"for background migrations"
fi
done
echo "target 19.${tgt_minor}: latest patch"Gitea's upgrade guide is shorter: download the new binary, stop the instance and back up, replace the binary, start it. It does not define mandatory stops. The constraint is elsewhere: moving between minor versions upgrades the database schema, and "since you can not run an old Gitea with an upgraded database, a backup should always be made before a database upgrade." Downgrade means restore.
The Gitea 28.0.0 release drops the historical "1." prefix, so the release after the 1.27 line is 28.0.0. Among its breaking changes: migrations, mirrors and other Git network operations now go through an internal proxy that applies egress settings: [migrations] EGRESS_MODE covers migrations and mirrors, [security] EGRESS_MODE covers webhooks and OAuth2, the external preset is removed, and invalid [migrations] BLOCKED_HOST_LIST entries stop Gitea from starting. The proxy covers HTTP(S) and git://, not SSH remotes. The default EGRESS_MODE is lax: there a portless allow entry covers every port, and public hosts are allowed on every port whether or not they are listed. Only in strict mode does a portless entry admit just ports 80 and 443, so a git:// mirror then needs an explicit :9418. Settings for retention, registration and ROOT_URL follow in the snippet. Job-level if: is evaluated before the matrix is expanded and may only use the github, gitea, needs, vars and inputs contexts, and matrix fail-fast is now enforced. The post lists more; read it in full first.
One rule in the config cheat sheet is easy to miss. "Private and loopback addresses need a builtin or CIDR entry." A host name on the allow list does not authorize the address it resolves to when that address is private, so an internal GitLab you mirror from, or an identity provider on the LAN, is refused even though its name is listed. Add its CIDR, or the private or loopback builtin; private covers RFC 1918, RFC 4193 and the RFC 6598 CGNAT range. Link-local addresses, 169.254.169.254 included, are denied by default and are not part of the private builtin, but an explicit CIDR such as 169.254.0.0/16 allows them, in strict mode too. private is also wider than it looks: its RFC 6598 range contains cloud-metadata addresses such as 100.100.100.200. Only the addresses Gitea classes as reserved stay denied even when listed. Domain entries follow curl syntax: example.com matches that host and every subdomain, *.example.com matches only subdomains and not the apex, and example.* is invalid. Pick the form that matches the scope you intend; a bare gitlab.example.com also admits anything under it. Test a mirror, a webhook and an OAuth2 login on a staging copy before the production upgrade.
; Decide BEFORE moving from 1.27.x to 28.x. Also check `git --version`:
; Gitea 28 refuses to start with Git older than 2.25.0 (not an ini key).
[server]
; DOMAIN is no longer read; the domain, including SSH, comes from ROOT_URL.
ROOT_URL = https://git.example.com/
[service]
; Self-registration is now off unless this is explicitly false.
DISABLE_REGISTRATION = true
[migrations]
; Migrations and mirrors (http, https, git; no SSH). Strict: a bare host
; allows only 80 and 443; list other ports. No IP wildcards.
EGRESS_MODE = strict
; A host name never authorizes a private or loopback address: add a CIDR
; or the private/loopback builtin. Link-local needs its own CIDR, and
; private includes CGNAT metadata such as 100.100.100.200.
; Curl syntax: a domain also matches all its subdomains; *.example.com
; matches subdomains only, not the apex; example.* is invalid.
ALLOWED_HOST_LIST = gitlab.example.com, git.example.com:8443, 10.20.0.0/16:8443
[security]
; Webhooks and OAuth2. In lax mode this list no longer restricts public hosts.
EGRESS_MODE = strict
; Same rules: an internal CI or IdP needs its CIDR, not just its name;
; a domain matches its subdomains too; a portless entry allows only
; 80 and 443, so add :port for an IdP on another port.
ALLOWED_HOST_LIST = ci.example.com, auth.example.com, 10.20.0.0/16
[actions]
; Default deletes completed runs, logs and artifacts after 400 days;
; 0 keeps them forever.
RUN_RETENTION_DAYS = 0
[audit]
; Off by default. Retention defaults to 30 days; 0 keeps forever.
RECORD_OUTPUT = database
; 400 is an example audit period, not the default; set your own.
RETENTION_DAYS = 400Backups, restores and the secrets file
GitLab's backup guide gives three steps: run the backup command, back up object storage if applicable, and manually back up system configuration files. That simple procedure is scoped, and the scope matters: it is for "GitLab environments with a PostgreSQL database smaller than 100 GB and where the backup command remains practical for your repository data." The same page warns that as data grows the command takes longer, and that "at some point, the backup command becomes impractical by itself", with 24 hours or more given as an example. Concurrent and incremental repository backups help for a while; past that point the page sends you to its scaling options, and the backup becomes a project of its own. Size the instance against that threshold before you need it, not on the night a restore is due. Within the scope, the Rake task "does not store your configuration files", and gitlab-secrets.json holds the database encryption key: lose it and GitLab "will not be able to decrypt any encrypted values in the database." At minimum, back up /etc/gitlab/gitlab-secrets.json and /etc/gitlab/gitlab.rb separately.
The exclusion list goes further: Redis and therefore Sidekiq jobs, object storage, global server hooks, file hooks, TLS- and SSH-related keys and certificates. Elasticsearch, if you run it for advanced search, is not backed up either; the page says its data can be regenerated from PostgreSQL after a restore, so budget the reindex time into the recovery estimate rather than the backup window. Uploads, CI artifacts, LFS objects and registry images are in the archive when they live on local disk; the same data on object storage is not. The two pages use different words for the same store: the architecture page calls Redis a non-persistent job backend, while the backup page treats its Sidekiq data as state worth keeping. Because Redis is excluded, a consistent backup needs "no pending or running background jobs." And a backup restores only to "exactly the same version and type (CE or EE)" that created it, per the restore guide.
#!/usr/bin/env bash
set -euo pipefail
DATA_DEST="/mnt/backup-data/gitlab" # receives the Rake tarball
SECRETS_DEST="/mnt/backup-secrets/gitlab" # different volume, tighter access
SERVER_HOOKS_DIR="${SERVER_HOOKS_DIR:-}" # your global server hooks dir, if any
STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
CONF_OUT="${SECRETS_DEST}/${STAMP}"
# Redis (Sidekiq's job data) is not in the backup: with jobs pending or
# running it is not consistent. Checked at start only; use a quiet window.
inflight="$(gitlab-rails runner \
's = Sidekiq::Stats.new; puts s.enqueued + s.scheduled_size + s.retry_size + s.workers_size' |
tail -n 1)"
if [[ "${inflight}" != "0" ]]; then
echo "Sidekiq reports ${inflight} pending or running jobs; not backing up." >&2
exit 1
fi
install -d -m 0700 "${CONF_OUT}" "${DATA_DEST}"
# Restore needs exactly the same version and edition (CE or EE).
head -n 1 /opt/gitlab/version-manifest.txt >"${CONF_OUT}/gitlab-version.txt"
# Local-disk uploads, artifacts, LFS and registry are in the archive;
# on object storage they are NOT. Copy the buckets separately.
gitlab-backup create
latest="$(find /var/opt/gitlab/backups -maxdepth 1 -name '*_gitlab_backup.tar' \
-printf '%T@ %p\n' | sort -n | tail -n 1 | cut -d' ' -f2-)"
cp -a "${latest}" "${DATA_DEST}/"
# Not in the tarball: secrets (database encryption key), config, keys, hooks.
cp -a /etc/gitlab/gitlab-secrets.json /etc/gitlab/gitlab.rb "${CONF_OUT}/"
if [[ -d /etc/gitlab/ssl ]]; then cp -a /etc/gitlab/ssl "${CONF_OUT}/"; fi
if [[ -d /etc/gitlab/trusted-certs ]]; then
cp -a /etc/gitlab/trusted-certs "${CONF_OUT}/"
fi
cp -a /etc/ssh/ssh_host_* "${CONF_OUT}/"
if [[ -n "${SERVER_HOOKS_DIR}" ]]; then
cp -a "${SERVER_HOOKS_DIR}" "${CONF_OUT}/server-hooks"
fiGitea's backup guide is simpler and has its own trap. gitea dump saves the installation to a ZIP, and "to ensure the consistency of the Gitea instance, it must be shutdown during backup." But the SQL inside the ZIP is an XORM dump, and the same page says admins may prefer the native MySQL and PostgreSQL tools because open XORM issues "may cause problems when attempting to restore it." So take the database natively and use the ZIP for repositories, configuration and data files. gitea dump includes its own SQL unless told otherwise, which is why the script passes --skip-db: there is then exactly one database copy, and it is the one you would actually restore. The script uses pg_dump --format=custom, which is restored with pg_restore, not psql; Gitea's own example uses plain SQL redirected to gitea-db.sql, and either is a native dump. Keep the database server running while Gitea is stopped; in a compose layout that means stopping the gitea container, not the db one.
#!/usr/bin/env bash
# PostgreSQL stays up (pg_dump needs a running server); only Gitea stops.
set -euo pipefail
APP_INI="/etc/gitea/app.ini"
GITEA_BIN="/usr/local/bin/gitea" # ExecStart= in Gitea's systemd unit
WORK_DIR="/var/lib/gitea" # its WorkingDirectory= and GITEA_WORK_DIR
RUN_AS="git" # its User= and Group=
STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
OUT="/mnt/backup-data/gitea/${STAMP}"
# Database name from app.ini's [database] NAME, not hard-coded.
DB_NAME="$(awk -F'=' '/^\[/ { sec = $0 }
sec == "[database]" && $1 ~ /^[ \t]*NAME[ \t]*$/ {
v = $2; gsub(/^[ \t"`]+|[ \t"`]+$/, "", v); print v }' "${APP_INI}")"
if [[ -z "${DB_NAME}" ]]; then
echo "No [database] NAME in ${APP_INI}" >&2
exit 1
fi
trap 'systemctl start gitea' EXIT
install -d -o root -g "${RUN_AS}" -m 0750 "${OUT}"
install -d -o "${RUN_AS}" -g "${RUN_AS}" -m 0700 "${OUT}/dump"
systemctl stop gitea
# gitea dump's SQL is an XORM dump; Gitea's docs warn of "open issues ...
# that may cause problems when attempting to restore it." Use pg_dump.
sudo -u postgres pg_dump --format=custom "${DB_NAME}" |
tee "${OUT}/gitea-db.pgdump" >/dev/null # restore with pg_restore
# MySQL instead: mysqldump --single-transaction "${DB_NAME}" | tee "${OUT}/gitea-db.sql"
# Repositories, app.ini and data files; --skip-db omits the XORM SQL.
# sudo resets the environment, so pass GITEA_WORK_DIR explicitly: a
# relative APP_DATA_PATH resolves against it, not the current directory.
sudo -u "${RUN_AS}" env GITEA_WORK_DIR="${WORK_DIR}" \
"${GITEA_BIN}" dump --config "${APP_INI}" --skip-db \
--file "${OUT}/dump/gitea-dump.zip" --tempdir "${OUT}/dump"
"${GITEA_BIN}" --version >"${OUT}/gitea-version.txt"Neither backup is proven until it has been restored on a clean host. The discipline in immutable backups and restore drills applies to both, and if the database is moving onto Kubernetes, CloudNativePG changes how the native dump is taken, not whether it is needed.
What an auditor is shown
On a free GitLab licence, the audit trail is thinner than the product's reputation suggests. GitLab's audit events page states that "successful sign-in events are the only audit events available at all tiers", shown in each user's authentication log. Group and project audit events are Premium and Ultimate; so are instance audit events under Admin > Monitoring > Audit events, and their CSV export. A free instance does produce evidence, but not a record of who changed which permission.
Gitea 28.0.0 added audit logging. It is off by default, enabled with [audit] RECORD_OUTPUT = database, and keeps events for 30 days unless RETENTION_DAYS is changed. Set retention to match your audit period the day you enable it.
Licensing is part of what you show procurement. Gitea is MIT-licensed. The GitLab repository's LICENSE splits the codebase rather than naming one licence. Content under doc/ is CC BY-SA 4.0; content under ee/ follows ee/LICENSE, and content under jh/ follows jh/LICENSE; client-side JavaScript is MIT Expat; third-party components keep the licence of their owners; and content outside those directories and restrictions is MIT Expat. A procurement review that needs a single answer should record that split, and record which edition is installed, because the Linux package you run is either CE or EE. Neither "GitLab is MIT" nor "GitLab is proprietary" is accurate alone.
Exit ramps: moving between them, and the third option
Gitea to GitLab is documented. GitLab's Gitea importer brings across repository data, the description, issues, pull requests, milestones and labels; diff notes from pull requests are not imported, so inline comments on pull-request diffs do not come across. On current GitLab the importer uses post-migration mapping, generally available for Gitea in GitLab 18.6: contributions and memberships land first on placeholder users, and an administrator reassigns them to real accounts afterwards. Two exceptions matter for planning. Importing into a personal namespace assigns every contribution to the namespace owner, and those cannot be reassigned. Contributions from deleted Gitea users are mapped to the user who ran the import. The importer page itself still carries an older paragraph saying the project creator becomes the author; the mapping page describes current behaviour.
GitLab to Gitea is less clear. Gitea's migration page points GitLab users to a non-affiliated third-party tool, while Gitea's source contains a GitLab downloader with methods for milestones, labels, releases, issues, comments, pull requests and reviews. The code exists; no document promises what it preserves. Test it first.
The third option is Forgejo, which became a hard fork of Gitea in early 2024. Its upgrade guide supports moving from Gitea "up to Gitea v1.22 included, in two steps": to Forgejo 10.0.x first, then onward. Gitea 1.23 and later, including the 1.27 line and 28, has no supported path. If Forgejo is your intended exit, that window has already closed for current Gitea installs, so make the decision before upgrading rather than after. The project's history and governance are covered in the Forgejo CI/CD article; for what a hosted forge lets you take with you, see what you own versus rent.
The long game: choose the forge you will still upgrade in 2029
A forge outlives the team that chose it. A capable platform two stops behind its security window is weaker than a modest forge patched last week. Before choosing, answer four questions in writing:
- Who is on call? Name the people who will run a GitLab upgrade with required stops and background migrations every quarter, or a Gitea upgrade with a one-way schema change.
- What CI do you need? GitHub-Actions-compatible workflows with an external runner, or GitLab's integrated pipelines.
- What audit tier do you need? If permission-change evidence is required, budget for GitLab Premium or enable and retain Gitea's audit log from day one.
- What is the upgrade window discipline? A calendar slot per month or quarter, a restore test per upgrade, and a written exit path that you re-check whenever a fork's supported window moves.
Both are open source, both can be run entirely on infrastructure you own, and both can be left. The choice that serves sovereignty is the one whose operating cost your team can actually carry, year after year.
§FAQ/Common questions
Frequently asked
Is Gitea or GitLab better for a small team?
Neither is better in general. Gitea is one Go binary and a database, which its docs say runs on 2 CPU cores and 1 GB of RAM for small teams. GitLab is a full platform with a single-node baseline of 8 vCPU and 16 GB. Pick the one your team can keep upgraded.
Can I skip GitLab versions when upgrading?
Only between required stops. From GitLab 17.5 onward the stops fall at x.2, x.5, x.8 and x.11. Install the latest patch of each stop and let its background migrations finish before the next hop. GitLab 19.8 and 19.11 were not yet released as of October 2026.
Is gitea dump enough to back up Gitea?
Not for the database. Gitea's docs warn that the XORM SQL dump inside the ZIP has open issues that may break a restore, so take PostgreSQL or MySQL with pg_dump or mysqldump while Gitea is stopped, and use gitea dump for repositories, config and data files.
Can I migrate from Gitea to GitLab?
Yes. GitLab's Gitea importer brings repositories, issues, pull requests, milestones and labels, but not pull-request diff notes. Contributions land on placeholder users for an admin to reassign, with two exceptions. Imports into a personal namespace go to the namespace owner. Contributions from deleted Gitea users go to the user who ran the import.
Can I move from Gitea 28 to Forgejo?
Not on a supported path. Forgejo's upgrade guide supports Gitea only up to v1.22 inclusive, via Forgejo 10.0.x first. Gitea 1.23 and later, including 28, is outside that window.
Further reading
- Git, GitHub, GitLab: What You Own and What You Rent
- Forgejo, Woodpecker and Zot: CI Off GitHub Actions
- GitHub Self-Hosted Runners on Kubernetes with ARC
- Own the Registry: Harbor and Zot for Air-Gapped Images
- Immutable Backups: Object Lock and Proving You Can Restore
- PostgreSQL on Kubernetes with CloudNativePG
Executive Briefing
Thirty minutes to clarify your infrastructure risk
Walk us through your vendor footprint and regulatory constraints. We will tell you honestly where sovereignty creates leverage — and where it does not. No pitch deck. No obligation.