DEV Community

DeployLark
DeployLark

Posted on Originally published at deploylark.com

GitLab CE vs Forgejo: Self-Hosted Git on Docker, Measured

GitLab CE and Forgejo both give you a private Git server with issues, code review, CI and a container registry. They sit at opposite ends of the weight scale. This comparison ran both under Docker Compose on the same box, pushed a real pipeline through each runner, pushed an image to each registry, migrated the same GitHub repository into both and ran each backup command. The numbers below come from that run on October 19, 2026.

TL;DR:

  • On an 8 vCPU host, Forgejo 15.0.10 with Postgres and a runner idled at 285.7 MiB of RAM and under 1% of one CPU core. GitLab CE 19.5.0 with its runner idled at 8,722.6 MiB with default settings. The GitLab container alone dropped to 2,466.5 MiB with the memory-constrained settings from the GitLab docs.
  • Forgejo answered its health check 5.4 seconds after docker compose up -d. GitLab served its sign-in page after 145 seconds.
  • Both ran a trivial CI job, accepted a docker push to the built-in registry, migrated a GitHub repo and wrote a backup archive of 15.6 MB (Forgejo) or 16.2 MB (GitLab).
  • GitLab CE is MIT licensed, with paid features under a separate EE license. Forgejo is GPL v3 or later. Forgejo federation is experimental and off by default.
  • Pick Forgejo for a small team or a homelab VPS. Pick GitLab CE when you want its integrated CI/CD and project management and can give it 8 GB or more of RAM.

Table of contents

The measured numbers

Idle RAM and CPU

Each stack ran alone on the same 8 vCPU host. The numbers are idle averages from docker stats, taken after each stack had run a pipeline, a registry push, a migration and a backup. 100% CPU means one full core.

Bar chart of idle memory on the same 8 vCPU host. Forgejo 15.0.10 stack with server, Postgres and runner: 285.7 MiB. GitLab CE 19.5.0 with memory-constrained settings, GitLab container only: 2,466.5 MiB. GitLab CE 19.5.0 with default settings plus runner: 8,722.6 MiB.

Stack Avg RAM Peak RAM Avg CPU Peak CPU
Forgejo 15.0.10 + Postgres 17.11 + runner 13.2.0 285.7 MiB 300.1 MiB 0.57% 0.89%
GitLab CE 19.5.0 default + runner 19.5.0 8,722.6 MiB 8,797.6 MiB 16.50% 87.05%
GitLab CE 19.5.0 memory-constrained (container only) 2,466.5 MiB 2,537.5 MiB 9.29% 74.12%

The constrained row leaves out the runner, so add about 65 MiB to compare it with the others. GitLab's default memory also depends on the server. Per the GitLab Puma docs, it starts one Puma worker per CPU, limited by RAM at about 1 GB each, so a 2 vCPU server starts fewer workers than this 8 vCPU host did.

Inside Forgejo, the server process took about 204 MiB, Postgres about 73 MiB and the idle runner about 8 MiB. GitLab is one container with many services. gitlab-ctl status listed 16 of them, including Puma, Sidekiq, Gitaly, PostgreSQL, Redis, Prometheus, Alertmanager, the registry and several exporters. With default settings, ps inside the container showed ten Ruby processes at roughly 1 GB of resident memory each, and the cgroup reported 8,022 MiB of anonymous memory. The GitLab runner added about 65 MiB.

The default GitLab number is in line with what people report on the GitLab forum. In a 2018 thread, one user who had just installed the gitlab-ce container with a single account and no code saw 8.452 GB in docker stats. The installation requirements set 16 GB and 8 vCPU as the single-node baseline, and at least 8 GB for memory-constrained setups.

The memory-constrained profile comes from the GitLab docs page for small environments, which states that "With some adjustments, GitLab can run comfortably on much lower specifications than described in minimum requirements or the reference architectures." The lab applied the page's Puma, Sidekiq, Prometheus and jemalloc settings, plus the Gitaly spawn limit, but not the Gitaly per-repo concurrency block. On this host that cut the GitLab container's idle RAM by about 72%, and a pipeline still ran green. The same page asks for at least 2 GB of RAM plus 1 GB of swap and warns that functionality and performance can degrade.

Forgejo 17.0.0, released October 15, 2026, idled at 315.3 MiB on the same host. As of October 19, 2026, the 17.0.0 release page advises against upgrading because of an issue that "prevents pushing, merging, or other code operations in some cases," so the rest of this guide uses the 15.0 LTS line.

Install time and steps

Forgejo 15.0.10 GitLab CE 19.5.0
Images forgejo 188 MB, postgres 297 MB, runner 46 MB gitlab-ce 4.07 GB, gitlab-runner 245 MB
Pull time on this host 3 to 7 s per image 53 s for gitlab-ce
up -d to ready 5.4 s (/api/healthz passes) 145 s (/users/sign_in returns 200)
Admin account forgejo admin user create GITLAB_ROOT_PASSWORD on first boot
Runner setup generate token, register, restart runner create runner in UI or API, register

Image sizes are the local uncompressed sizes from docker images. GitLab also pulled its helper image, gitlab-runner-helper:x86_64-v19.5.0 (112 MB), on the first job. Switching GitLab to the constrained settings and recreating the container took another 98 seconds before the sign-in page came back.

Install both with Docker Compose

Both stacks below publish ports on 127.0.0.1 only. Put a TLS proxy such as Nginx Proxy Manager in front before anyone else uses them, the same pattern as the Plausible CE guide.

Forgejo with Postgres and a runner

The Forgejo Docker docs start from a single server service and add Postgres as an option. The file below adds Postgres, skips the web installer with INSTALL_LOCK, and adds a runner that waits until it has been registered:

name: fj
services:
  server:
    image: codeberg.org/forgejo/forgejo:15.0.10
    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=change-me-db-password
      - FORGEJO__security__INSTALL_LOCK=true
      - FORGEJO__server__ROOT_URL=http://127.0.0.1:3000/
      - FORGEJO__server__SSH_PORT=2222
      - FORGEJO__actions__ENABLED=true
      - FORGEJO__actions__DEFAULT_ACTIONS_URL=https://data.forgejo.org
    restart: unless-stopped
    volumes:
      - fj-data:/data
    ports:
      - "127.0.0.1:3000:3000"
      - "127.0.0.1:2222:22"
    depends_on:
      - db
  db:
    image: postgres:17.11-alpine
    restart: unless-stopped
    environment:
      - POSTGRES_USER=forgejo
      - POSTGRES_PASSWORD=change-me-db-password
      - POSTGRES_DB=forgejo
    volumes:
      - fj-pg:/var/lib/postgresql/data
  runner:
    image: code.forgejo.org/forgejo/runner:13.2.0
    user: "0:0"
    restart: unless-stopped
    command: ["/bin/sh", "-c", "while [ ! -f /data/.runner ]; do sleep 2; done; exec forgejo-runner daemon --config /data/config.yml"]
    volumes:
      - fj-runner:/data
      - /var/run/docker.sock:/var/run/docker.sock
    depends_on:
      - server
volumes:
  fj-data:
  fj-pg:
  fj-runner:
Enter fullscreen mode Exit fullscreen mode

FORGEJO__actions__ENABLED=true and the default actions URL match the documented defaults and are written out so the file explains itself. Forgejo publishes no latest tag at all, by design, so pinning a full version like 15.0.10 is the normal path.

Start it, create an admin and register the runner:

docker compose up -d
docker compose exec -u git server forgejo admin user create --admin \
  --username labadmin --email you@example.com --password "$(openssl rand -hex 12)" \
  --must-change-password=false
TOKEN=$(docker compose exec -u git server forgejo actions generate-runner-token | tr -d "\r")
docker compose exec runner sh -c "cd /data && forgejo-runner generate-config > config.yml \
  && sed -i \"s/^  network: .*/  network: fj_default/\" config.yml \
  && forgejo-runner register --no-interactive --instance http://server:3000 \
     --token $TOKEN --name lab-runner --labels docker:docker://alpine:3.24.2"
docker compose restart runner
Enter fullscreen mode Exit fullscreen mode

The network: fj_default line puts job containers on the Compose network so they can reach http://server:3000. The runner mounts the Docker socket, which gives it root-level control of the host. Run it on a separate machine if untrusted people can push workflows.

GitLab CE with a runner

The GitLab Docker install docs ask for an externally reachable hostname and say not to use localhost. The lab mapped gitlab.lab.test to 127.0.0.1 in /etc/hosts and gave the container the same name as a network alias, so the runner and job containers resolve it too:

name: gl
services:
  gitlab:
    image: gitlab/gitlab-ce:19.5.0-ce.0
    hostname: gitlab.lab.test
    restart: unless-stopped
    shm_size: "256m"
    environment:
      GITLAB_ROOT_PASSWORD: ${GITLAB_ROOT_PASSWORD}
      GITLAB_OMNIBUS_CONFIG: |
        external_url "http://gitlab.lab.test:8929"
        registry_external_url "http://gitlab.lab.test:5050"
        gitlab_rails["gitlab_shell_ssh_port"] = 2224
    ports:
      - "127.0.0.1:8929:8929"
      - "127.0.0.1:5050:5050"
      - "127.0.0.1:2224:22"
    volumes:
      - gl-config:/etc/gitlab
      - gl-logs:/var/log/gitlab
      - gl-data:/var/opt/gitlab
    networks:
      default:
        aliases:
          - gitlab.lab.test
  runner:
    image: gitlab/gitlab-runner:alpine-v19.5.0
    restart: unless-stopped
    volumes:
      - gl-runner:/etc/gitlab-runner
      - /var/run/docker.sock:/var/run/docker.sock
volumes:
  gl-config:
  gl-logs:
  gl-data:
  gl-runner:
Enter fullscreen mode Exit fullscreen mode

Put GITLAB_ROOT_PASSWORD= with a long random value in a .env file next to it and run docker compose up -d. Without that variable, GitLab writes a generated password to /etc/gitlab/initial_root_password inside the container and deletes it on the first container restart after 24 hours.

For the constrained run, these lines were appended to GITLAB_OMNIBUS_CONFIG and the container recreated:

# memory-constrained settings from docs.gitlab.com/omnibus/settings/memory_constrained_envs
puma["worker_processes"] = 0
sidekiq["concurrency"] = 10
prometheus_monitoring["enable"] = false
gitlab_rails["env"] = { "MALLOC_CONF" => "dirty_decay_ms:1000,muzzy_decay_ms:1000" }
gitaly["env"] = { "MALLOC_CONF" => "dirty_decay_ms:1000,muzzy_decay_ms:1000", "GITALY_COMMAND_SPAWN_MAX_PARALLEL" => "2" }
Enter fullscreen mode Exit fullscreen mode

Then create an instance runner under Admin, CI/CD, Runners (or with POST /api/v4/user/runners), copy the glrt- token and register:

docker compose exec runner gitlab-runner register --non-interactive \
  --url http://gitlab.lab.test:8929 --token glrt-REPLACE_ME \
  --executor docker --docker-image alpine:3.24.2 --docker-network-mode gl_default
Enter fullscreen mode Exit fullscreen mode

CI, registry, issues and merge requests

GitLab CI and Forgejo Actions

Each instance got a hello-ci repository with the same two-step job on alpine:3.24.2. The GitLab version lives in .gitlab-ci.yml:

hello:
  image: alpine:3.24.2
  script:
    - echo "hello from GitLab CI"
    - cat /etc/alpine-release
Enter fullscreen mode Exit fullscreen mode

The pipeline started on push and passed. The job page reported 4 seconds of run time after 1 second in the queue. With the constrained settings, the API reported 2.93 seconds for a rerun.

GitLab CE 19.5.0 job log for job 1 in root/hello-ci, status Passed. The log shows the runner pulling alpine:3.24.2, fetching the repository, running echo hello from GitLab CI and printing 3.24.2, then Job succeeded. The sidebar shows a duration of 4 seconds and runner number 1, lab-runner.

Forgejo reads workflows from .forgejo/workflows/, and falls back to .github/workflows/ when that folder is missing:

on: [push]
jobs:
  hello:
    runs-on: docker
    steps:
      - run: echo "hello from Forgejo Actions"
      - run: cat /etc/alpine-release
Enter fullscreen mode Exit fullscreen mode

runs-on: docker matches the label given at registration. The run passed, and the Actions page reported about 1 second for the job.

Forgejo 15.0.10 Actions run page for labadmin/hello-ci, workflow hello.yml triggered by the add workflow commit. The hello job succeeded. Log sections show Set up job with runner version v13.2.0 starting image alpine:3.24.2 on network fj_default, then the echo step printing hello from Forgejo Actions, the cat step printing 3.24.2, and Complete job.

The syntax looks like GitHub Actions on purpose. The Forgejo Actions docs say: "Forgejo Actions is designed to be familiar to users of GitHub Actions, but it is not designed to be compatible." Expect small edits when you bring workflows over. GitLab CI uses its own YAML format, so moving between the two means a rewrite either way.

Registry, issues and code review

Both ship a container registry. On GitLab it is a separate bundled service that turns on when you set registry_external_url, here on port 5050. The requirements page lists a domain and TLS certificates for it in real use. The lab used plain HTTP on a loopback name, which Docker accepts because it treats 127.0.0.0/8 as an insecure registry by default. For any other address, Docker refuses plain HTTP unless the registry is listed under insecure-registries, so put both registries behind HTTPS before you push from another machine. On Forgejo, the registry is part of the package registry and lives on the main web port, so the image name is 127.0.0.1:3000/labadmin/alpine-copy:3.24.2. Both pushes of the same Alpine image succeeded with the same digest, sha256:7cefa58b..., after a docker login with an access token.

Issues and code review worked the same way on both. An issue, a branch, a commit and a pull or merge request with Closes #1 took a handful of API calls. Both linked the issue and showed the passing pipeline on the review page.

GitLab CE merge request Tweak README, number 1, open from branch readme-tweak into main. The description says Closes number 1, a Pipeline number 3 passed widget shows a green check, approval is optional, and Ready to merge offers a Merge button with Delete source branch checked. The sidebar lists Lab issue: check CI badge under work items it closes.

GitLab wraps that core in more DevOps menus. The CE project sidebar in the screenshots groups them under Plan, Code, Build, Secure, Deploy, Operate, Monitor and Analyze. Forgejo keeps the GitHub-style layout: issues, pull requests, projects, packages, wiki and Actions as tabs on the repository.

Forgejo 15.0.10 pull request Tweak README number 2 in labadmin/hello-ci, open, wanting to merge 1 commit from readme-tweak into main. The comment says Closes number 1. A checks box reads All checks were successful with hello push successful in 1s, the pull request can be merged automatically, and a Create merge commit button is shown.

Migrations, upgrades and backups

Migrating a real repository

Both instances imported https://github.com/plausible/community-edition over HTTPS as plain Git. Forgejo returned from POST /api/v1/repos/migrate in 0.4 seconds with 31 commits and 11,425 KB on disk.

GitLab 19.5.0 refused at first. On a fresh install, import_sources was an empty list, so import by URL was off and the API returned 403. Enabling git and github under Admin, Settings, General, Import and export settings fixed it, but the same request kept returning 403 for about three minutes before it succeeded. After that, the import finished within a few seconds, with the same 31 commits and 11.4 MB.

For a move away from GitLab, the Forgejo migrator can also bring issues, labels, milestones, releases and merge requests when you give it a GitLab access token. The Codeberg migration docs list GitLab among the services with metadata support. Forgejo uses one number space for issues and pull requests, so merge request numbers shift on the way in. Forgejo pull request #1790 offsets them by the highest issue number, and pull request #10077, merged in November 2025, rewrites !N references in comments to match.

Upgrades and backups

GitLab ships a minor release every month on the third Thursday, patch releases twice a month and one major release a year, per its maintenance policy. Security fixes reach the current release and the two before it. Jumps between versions must pass through required upgrade stops. For GitLab 19 those are 19.2, 19.5, 19.8 and 19.11, so the tested 19.5.0 is itself a stop. The Docker upgrade docs say to back up at least the database and the secrets file, upgrade the runner to the same version, change the image tag, then run docker compose pull and docker compose up -d. The upgrade path page says to let background migrations finish before the next stop.

Forgejo releases quarterly, with one LTS a year. The release schedule gives 15.0 LTS support until July 15, 2027, while 16.0 reaches end of life on October 29, 2026. The FAQ says major upgrades need a manual check, which is why the project publishes no latest tag. Inside a line, the 15 and 15.0 tags follow the newest minor and patch release.

For backups, GitLab: docker compose exec gitlab gitlab-backup create ran in 24.6 seconds and wrote a 16.2 MB tar to /var/opt/gitlab/backups/. It ended with a warning that gitlab.rb and gitlab-secrets.json are not in the archive. The warning adds: "You will need these files to restore a backup." Copy both separately.

Forgejo: docker compose exec -u git -w /tmp server forgejo dump --type tar.gz --file /tmp/forgejo-dump.tar.gz ran in 0.4 seconds and wrote a 15.6 MB archive with the repositories, packages, forgejo-db.sql and app.ini. That app.ini holds SECRET_KEY, INTERNAL_TOKEN and JWT_SECRET, so treat the dump file as a secret. Copy either archive off the box and test a restore on a scratch instance.

Licensing, federation and which to pick

Licensing and federation

The GitLab LICENSE file puts the code outside the ee/ directory under the MIT Expat license, with the docs under CC BY-SA 4.0. Code in ee/ falls under the EE license, which allows production use only with a GitLab subscription. The gitlab-ce image is the MIT edition, and the 19.5.0 API reported "enterprise":false.

The Forgejo FAQ says versions 9.0 and later are GPL v3 or later, versions up to 8.0 were MIT, and the project is not AGPL.

Federation through ForgeFed is a stated Forgejo goal. The FAQ says: "Federation is under active development, and is considered experimental." It warns of breaking changes that can burn the domain for future federation. The config cheat sheet defaults [federation] ENABLED to false, and the ActivityPub user endpoint returned 404 on the 17.0.0 lab install. Federated stars were the first user-facing feature, per a FOSDEM 2025 talk. Treat federation as a future feature, not a reason to switch today.

Which one to pick

Pick Forgejo when:

  • The server is a small VPS, a homelab box or anything with 1 to 4 GB of RAM. The whole stack idled under 300 MiB here.
  • You want GitHub-style repos, pull requests, packages and Actions with little upkeep, and a quarterly release rhythm with a yearly LTS.
  • You prefer a copyleft license and a community project whose domains are held by the non-profit Codeberg e.V.

Pick GitLab CE when:

  • You want one integrated DevOps tool: mature CI/CD with stages, a merge request flow and planning in the same UI.
  • You can give it 8 GB of RAM or more, or accept the constrained profile and its trade-offs at about 2.4 GiB.
  • You may want paid EE features later. The memory-constrained docs page says you can always upgrade from CE to EE.

For a solo developer or a small team, Forgejo covers the same daily Git work. On this host it used about a thirtieth of the idle RAM of default GitLab. Add an Uptime Kuma monitor on the login page either way, and if you want push-to-deploy on top, connect the repo to Coolify.

FAQ

Resources and CI

Why does GitLab use so much RAM when idle?

It runs many services in one container, and on this 8 vCPU host the ten Ruby processes held about 1 GB each. A default 19.5.0 container idled at 8,657.7 MiB (about 8.5 GiB) here, close to the 8.452 GB that a user reported on a fresh container in the GitLab forum thread on high RAM usage. The memory-constrained settings brought it to about 2.4 GiB.

Can GitLab run on a 4 GB VPS?

It can start with the constrained profile, which idled at 2,466.5 MiB in this run. GitLab documents 8 GB as the floor for constrained single-node installs and 16 GB as the baseline. In a forum thread about memory after the 17.0 upgrade, a reply calls 4 GB the absolute minimum and recommends 8 GB.

Can Forgejo Actions run GitHub Actions workflows unchanged?

Not always. The Forgejo docs say Actions is designed to be familiar to GitHub users but not designed to be compatible, so expect small edits. Forgejo also falls back to .github/workflows/ when .forgejo/workflows/ is missing.

Migration, licensing and federation

Can you migrate from GitLab to Forgejo with issues and merge requests?

Yes, with a GitLab access token in the migration form. Merge requests become pull requests numbered after the highest issue number, and !N references in comments are rewritten since pull request #10077. Cross-repository references are still tracked in issue #11332.

Is Forgejo licensed under the AGPL?

No. The Forgejo FAQ says GPL v3 or later from version 9.0, MIT up to 8.0, and that switching to AGPL or EUPL was proposed without agreement.

Can Forgejo instances federate with each other yet?

Only experimentally. Federation is off by default, the FAQ calls it experimental, and moderation and access control are still missing. The 2025 FOSDEM talk marked federated stars as done and listed following, comments and pull requests across instances as roadmap items.

Originally published on DeployLark: GitLab CE vs Forgejo: Self-Hosted Git on Docker, Measured

Top comments (1)

Collapse
 
dhruonweb profile image
Dhruv Malaviya •

8,722.6 MiB idle versus 285.7 MiB is a 30x gap for the same job, and the 145-second sign-in page is the number that decides it for a homelab.

Worth putting those against what they cost to host. At Krova Cloud 2 vCPU / 4 GB / 40 GB is $10.22/mo, which runs your Forgejo stack with a lot of headroom. GitLab at default settings wants the 4 vCPU / 8 GB / 80 GB shape at $19.42 — and the memory-constrained config exists precisely because most people don't want to pay for the default.

There are Docker-ready images (ubuntu-24.04-docker, debian-13-docker), so the runtime is already there.

One addition for the CI section: a self-hosted runner never needs an inbound port. It polls out for jobs, so on a Cube with nothing mapped there's no surface to expose.