LUNAROPS · OPERATIONAL UPLINK 100% UPTIME 1,247d POSTS 893 JEFF.MOON@LUNAROPS.DEV UTC --:--:--

GitLab Self-Hosted

gitlabself-hostingci-cddevopsgit

GitLab is not a git forge with CI bolted on. It is an attempt to replace your entire software delivery toolchain with a single, opinionated platform: source control, CI/CD, container registry, package registry, issue tracking, wikis, environments, deployments, and a growing suite of security scanning tools — all in one process bundle, sharing one configuration file, one PostgreSQL instance, and one authentication system. That integration is both its greatest strength and the reason a base installation consumes more RAM than some teams’ entire Kubernetes nodes. Before you reach for it, you need to know what you are actually running.

The comparison that matters for most self-hosters is against Forgejo or Gitea: a single Go binary that idles at under 100 MB of RAM, speaks GitHub Actions syntax, and requires zero operational background knowledge to upgrade. GitLab CE is not that. It is a Ruby-on-Rails application wrapped in an Omnibus package that bundles PostgreSQL, Redis, Puma, Sidekiq, Gitaly, NGINX, Workhorse, GitLab Shell, and optionally Prometheus, the container registry, GitLab Pages, and more. The threshold question is whether the integrated CI/registry/security toolchain earns that weight for your specific use case. This guide helps you answer that question and, if the answer is yes, actually operate the thing.


What GitLab CE Bundles (and What Stays EE)

GitLab ships in two editions: Community Edition (CE), which is MIT-licensed and free, and Enterprise Edition (EE), which is source-available but license-gated at two tiers — Premium and Ultimate — with pricing per seat per month. The self-hosted EE binary is the same binary as CE; you activate features by entering a license key. Understanding the tier split matters before you architect around a feature.

CE includes: Git repository hosting, merge requests with code review, webhooks, GitLab CI/CD (unlimited pipelines and runners on self-hosted), the built-in container registry, the package registry (PyPI, npm, Maven, Helm, Conan, and others), GitLab Pages, issue boards, milestones, wikis, basic SAST (Static Application Security Testing via analyzers run in CI), secret detection, and dependency scanning. The security scanners run as ordinary CI jobs using GitLab-maintained analyzer images — you get the job templates and the reports, but the vulnerability management dashboard and policy enforcement live behind EE.

Premium (EE, paid) adds: code owners with approval rules, merge trains, multiple approval rules, group-level runners, epics, roadmaps, portfolio-level burndown charts, enhanced audit events, and priority support.

Ultimate (EE, paid) adds: DAST (Dynamic Application Security Testing), container scanning, license compliance, dependency list with full management UI, vulnerability management workflows, security policy enforcement, compliance pipelines, and AI features. DAST and container scanning jobs can technically be run from the CE analyzer images in CI, but the results only integrate meaningfully into the UI at Ultimate tier.

For most self-hosters, CE is sufficient and the SAST/secret-detection/dependency-scanning jobs — which run as standard .gitlab-ci.yml includes — provide genuine value at zero license cost.


Install Paths and Their Trade-offs

There are three supported installation methods, and each has a distinct operational profile.

Method Best for Upgrade mechanism Operational complexity
Omnibus package (Linux) Single VM, bare metal, homelab apt upgrade gitlab-ce with version pinning Low — one config file
Official Docker image Existing Docker hosts, quick eval Pull new image, recreate container Low-medium — data volumes matter
Helm chart / GitLab Operator Kubernetes, cloud-native HA helm upgrade with values diff High — requires K8s expertise

Omnibus on a VM

The Omnibus package is the canonical installation method. It bundles every GitLab component, their dependencies, and a supervisord-like process manager (gitlab-ctl) into a single .deb or .rpm. You configure everything in /etc/gitlab/gitlab.rb and run gitlab-ctl reconfigure to apply changes via Chef recipes bundled in the package. This is the path with the most documentation, the most community answers, and the simplest day-two operations.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# Install on Ubuntu/Debian
curl https://packages.gitlab.com/install/repositories/gitlab/gitlab-ce/script.deb.sh | sudo bash
sudo EXTERNAL_URL="https://gitlab.example.com" apt install gitlab-ce

# Key gitlab.rb fragments
external_url 'https://gitlab.example.com'

# Reduce memory pressure on smaller VMs
puma['worker_processes'] = 2
puma['per_worker_max_memory_mb'] = 1024
sidekiq['concurrency'] = 10

# Disable what you don't need
prometheus_monitoring['enable'] = false
gitlab_kas['enable'] = false
pages_enabled = false

After editing gitlab.rb, always run sudo gitlab-ctl reconfigure. For configuration that does not require reconfiguration (like log rotation), sudo gitlab-ctl restart suffices.

Docker

The official gitlab/gitlab-ce image is identical in behavior to the Omnibus package — it runs the same bundled services inside the container. You mount three volumes: config, logs, and data. The most important operational detail is that the gitlab-secrets.json and gitlab.rb files that live in config are not part of the image and must be backed up separately.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# docker-compose.yml
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
        puma['worker_processes'] = 2
        sidekiq['concurrency'] = 10
    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:

The shm_size: 256m matters — without it, PostgreSQL inside the container will complain about shared memory. Pinning a specific image tag (e.g., gitlab/gitlab-ce:17.9.1-ce.0) rather than latest is essential for upgrade discipline; we cover that in the upgrade section.

For more on composing multi-service homelab stacks, see the Docker Compose for the homelab post.

Helm Chart and GitLab Operator on Kubernetes

The GitLab Helm chart (gitlab/gitlab) is a cloud-native decomposition of GitLab where each component — Webservice (Puma + Workhorse), Sidekiq, Gitaly, Shell, Registry, Pages, and supporting services — runs as a separate Kubernetes Deployment. This enables horizontal scaling and HA configurations that Omnibus cannot match, but it comes at a steep operational cost: the Helm chart has hundreds of configurable values, requires external PostgreSQL and Redis (or you manage those as sub-charts too), requires object storage for most artifact types, and has a significantly more complex upgrade procedure.

The GitLab Operator is the OLM-based alternative targeting OpenShift and OLM-enabled clusters, with a GitLab CRD that wraps the Helm chart. For most homelab Kubernetes deployments, the Helm chart is more flexible.

The honest assessment: unless you are already running production Kubernetes and have strong Helm + K8s operational experience, do not deploy GitLab on Kubernetes. The Omnibus install on a dedicated VM is more reliable, easier to backup, easier to upgrade, and considerably easier to debug when something goes wrong. Kubernetes GitLab is worth it when you need multi-Webservice-pod HA, runner autoscaling via the Kubernetes executor, or a cloud-native hybrid architecture. For a reference, see the Kubernetes for the homelab post on whether your cluster is actually the right tool for this workload.


Component Architecture

Understanding what is running inside GitLab is essential for capacity planning, troubleshooting, and sensible configuration.

Client (browser / git client / API)
        |
        v
  [ NGINX / Let's Encrypt TLS termination ]
        |
        v
  [ GitLab Workhorse ]  <-- Go proxy; handles large uploads/downloads,
        |                    git pack operations, CI artifacts directly
        |
        +---> [ Puma (Ruby on Rails) ]  <-- web requests, API
        |           |
        |           v
        |     [ Sidekiq ]  <-- async background jobs (emails, webhooks,
        |                       pipeline scheduling, housekeeping)
        |
        +---> [ Gitaly ]  <-- gRPC service owning all git repository I/O
        |         |
        |         v
        |     [ Git repos on disk ]
        |
        +---> [ GitLab Shell ]  <-- SSH git operations
        |
        +---> [ Container Registry ]  <-- Docker distribution
        |
        +---> [ PostgreSQL ]  <-- primary relational store
        |
        +---> [ Redis ]  <-- sessions, Sidekiq queues, cache
        |
        +---> [ Prometheus + exporters ]  <-- metrics (optional)


Runner Architecture (external to GitLab server):

  [ GitLab Server ]
        |
        | HTTPS polling / webhook
        v
  [ gitlab-runner manager process ]
        |
        +---> shell executor (runs jobs directly on runner host)
        |
        +---> docker executor (spins job container per job)
        |
        +---> docker-autoscaler executor (provisions cloud VMs on demand)
        |
        +---> kubernetes executor (creates pods in a K8s cluster)

Gitaly deserves special mention. Before GitLab 13, git operations went through an NFS share or were executed directly by Ruby code. Gitaly is now the sole interface to repository data — every git call, whether from Puma, Sidekiq, or GitLab Shell, goes through the Gitaly gRPC service. This is architecturally sound and enables future Gitaly Cluster (HA) configurations, but it means there is an extra process with its own memory footprint for even a single-node installation.


Runner Architecture and Pipeline Execution

GitLab CI/CD is pull-based: the gitlab-runner process polls the GitLab API for pending jobs and claims them. You can register runners at the instance level (shared, available to all projects), the group level (available to all projects in a group), or the project level (exclusive to one project).

Runner Registration: New Authentication Tokens

Since GitLab 16.0, runner registration uses authentication tokens instead of the legacy registration tokens. The old --registration-token workflow was deprecated and disabled by default from GitLab 17.0 onward. New tokens are created in the GitLab UI (Admin > CI/CD > Runners > New instance runner, or at group/project level) and are recognizable by the glrt- prefix. Attempting to register with a legacy token on GitLab 17+ returns a 410 Gone error.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# Install gitlab-runner (Debian/Ubuntu)
curl -L https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.deb.sh | sudo bash
sudo apt install gitlab-runner

# Register a runner using the new authentication token workflow
sudo gitlab-runner register \
  --url https://gitlab.example.com \
  --token glrt-xxxxxxxxxxxxxxxxxxxx \
  --executor docker \
  --docker-image alpine:latest \
  --description "docker-runner-01" \
  --tag-list "docker,linux" \
  --run-untagged=false \
  --locked=false

The token is stored in /etc/gitlab-runner/config.toml after registration. Each runner registered with the same authentication token gets a unique system_id, tracked separately — re-registering with the same token creates a new runner record rather than overwriting the old one.

Executors

Shell: Runs jobs directly on the host where gitlab-runner is installed. No isolation. Fast, low overhead, useful for build jobs that need direct hardware access, but jobs share the host environment and can interfere with each other or the host OS. Avoid for untrusted code.

Docker: Each job spins up a fresh container from the image: specified in .gitlab-ci.yml, runs, and is destroyed. This is the most common executor for homelab and small team setups. Provides isolation without the overhead of full VMs.

Docker Autoscaler: GA since GitLab Runner 17.1, this replaces the deprecated Docker Machine executor. It uses the fleeting plugin system to provision cloud VMs (AWS, GCP, Azure) on demand and tear them down when idle. Each VM runs a Docker executor. Ideal for workloads with spiky CI demand. The Docker Machine executor was deprecated in 17.5 and is scheduled for removal in GitLab 20.0 (May 2027).

Kubernetes: Runs each CI job as a Kubernetes Pod. The runner manager creates a pod per job and deletes it on completion. Scales efficiently within a cluster, enables resource requests/limits per job, and is the natural choice when your runners live inside a Kubernetes cluster. For a discussion of running CI on Kubernetes, see the self-hosted GitHub Actions runners post for the equivalent pattern on the Actions side.

A Minimal Pipeline

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# .gitlab-ci.yml
stages:
  - build
  - test
  - publish

variables:
  IMAGE: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA

build-image:
  stage: build
  image: docker:26
  services:
    - docker:26-dind
  script:
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
    - docker build -t $IMAGE .
    - docker push $IMAGE
  tags:
    - docker

unit-tests:
  stage: test
  image: golang:1.22
  script:
    - go test ./...
  tags:
    - docker

include:
  - template: Security/SAST.gitlab-ci.yml
  - template: Security/Secret-Detection.gitlab-ci.yml

The $CI_REGISTRY, $CI_REGISTRY_USER, and $CI_REGISTRY_PASSWORD variables are automatically populated by GitLab when the built-in container registry is enabled — no external registry credentials to manage. Including the SAST and Secret-Detection templates adds security scan jobs to the pipeline at zero additional configuration cost on CE.


Resource Footprint: The Honest Numbers

GitLab documentation lists 4 GB RAM as the absolute minimum and 8 GB as the recommended floor for a production single-node install serving up to 500 users. In practice, after running gitlab-ctl reconfigure on a fresh install, resident memory consumption before any real workload looks roughly like this:

Component Typical RSS (idle)
Puma (2 workers + master) 900 MB – 1.4 GB
Sidekiq 300 – 600 MB
Gitaly 200 – 400 MB
PostgreSQL 200 – 400 MB
Redis 50 – 100 MB
NGINX + Workhorse 50 – 100 MB
Container Registry 50 – 100 MB
Prometheus + exporters 150 – 300 MB
Total (idle) ~2 – 3.4 GB

That is idle. Under active CI load — multiple concurrent pipeline jobs, artifact uploads, merge request diff rendering, webhook delivery — Puma workers can hit their 1.2 GB per-worker ceiling and trigger restarts, Sidekiq threads multiply, and Gitaly spikes on large repository operations. A 4 GB VM is survivable only with aggressive tuning (disabling Prometheus, reducing Puma workers to 2, reducing Sidekiq concurrency to 10, disabling KAS and Pages). An 8 GB VM is comfortable for a small team. 16 GB is where you stop worrying.

Compare this to Forgejo: a single binary consuming under 100 MB at idle, with SQLite as the default backend. GitLab’s footprint is not a flaw — it is the direct cost of bundling PostgreSQL, a background job system, a gRPC repository service, and a full metrics pipeline into one product. But it is a real cost that must be provisioned for before you commit to the path.


Backup and Restore

GitLab’s backup Rake task (gitlab-backup create) produces a tar archive containing: the database dump, repositories (via Gitaly), uploads, build artifacts, LFS objects, pages, registry data (optional), and CI job traces. What it explicitly does NOT include:

  • /etc/gitlab/gitlab.rb — your entire configuration
  • /etc/gitlab/gitlab-secrets.json — encryption keys for 2FA OTP secrets, CI variable encryption, and database column encryption

This separation is intentional: bundling the secrets file with the data backup defeats the purpose of encryption at rest. But it means a restore from backup alone will produce a running GitLab instance where 2FA is broken, CI secure variables are unreadable, and integrations using stored credentials fail. You must store gitlab.rb and gitlab-secrets.json separately, in a different location from your backup archive.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# Create a backup (Omnibus)
sudo gitlab-backup create

# Backup the config separately (stores to /etc/gitlab/config_backup/)
sudo gitlab-ctl backup-etc

# List backups
ls /var/opt/gitlab/backups/

# Restore (GitLab must be at the same version as the backup)
sudo gitlab-ctl stop puma
sudo gitlab-ctl stop sidekiq
sudo gitlab-backup restore BACKUP=1748908800_2026_06_03_17.9.1
sudo gitlab-ctl start
sudo gitlab-rake gitlab:check SANITIZE=true

For Docker deployments, the backup runs inside the container:

1
2
3
4
5
6
docker exec -t gitlab gitlab-backup create
docker exec -t gitlab gitlab-ctl backup-etc

# Copy secrets out of the container
docker cp gitlab:/etc/gitlab/gitlab-secrets.json ./gitlab-secrets.json
docker cp gitlab:/etc/gitlab/gitlab.rb ./gitlab.rb

Automate both backup creation and the config backup separately. Storing the backup archive and the secrets in the same S3 bucket is acceptable (they are independently secured), but do not rely on the Rake backup alone — without the secrets, the backup is only partially recoverable.


The Upgrade Reality

GitLab’s upgrade path enforces required stop versions: specific versions you must pass through, in order, before advancing to a later release. You cannot jump from 15.4 to 17.0 in one step. Required stop versions exist because background database migrations run asynchronously during normal operation, and skipping a stop version before those migrations complete corrupts the schema or the data.

From GitLab 17.5 onward, required stops occur at x.2, x.5, x.8, and x.11 minor versions within each major series. GitLab maintains an official upgrade path calculator at gitlab-com.gitlab.io/support/toolbox/upgrade-path/ — always use it before planning an upgrade.

The procedure for each stop:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
# 1. Take a full backup before any upgrade
sudo gitlab-backup create
sudo gitlab-ctl backup-etc

# 2. Pin to the specific stop version
sudo apt install gitlab-ce=17.2.0-ce.0

# 3. Reconfigure and restart
sudo gitlab-ctl reconfigure
sudo gitlab-ctl restart

# 4. Wait for background migrations to complete before proceeding
# Check via Admin > Monitoring > Background Migrations
# Or via Rails console:
sudo gitlab-rails runner "puts Gitlab::BackgroundMigration.remaining"

# 5. Proceed to next stop only after migrations show 0 remaining
sudo apt install gitlab-ce=17.5.0-ce.0

For the Docker image, the same discipline applies: pull the specific target version tag, recreate the container, and wait for migrations before pulling the next stop version. The practical implication for homelab operators: if you let a GitLab installation drift for six months without updates, returning to current may require touching four or five intermediate versions with a migration wait at each. This is not hypothetical — it is routine. Set a monthly calendar reminder to upgrade.


GitLab CE vs. Forgejo: When Each Wins

This is the decision most self-hosters are actually making.

Factor GitLab CE Forgejo
RAM at idle 2–3.5 GB (tuned), 4–8 GB practical 50–150 MB
Disk (install) ~2.5 GB + data ~100 MB binary
CI/CD Native, deeply integrated Forgejo Actions (GitHub Actions compatible)
Container Registry Built-in, zero config Built-in since Forgejo 1.21
Package Registry Built-in (many formats) Limited (npm, PyPI via external)
SAST / Secret Detection Native CI templates (CE) Via external Action steps
Issue boards / Kanban Yes Yes (basic)
Upgrade complexity High — required stop versions Low — binary swap
Single binary? No — 8+ processes Yes
License MIT (CE) MIT
CI syntax .gitlab-ci.yml GitHub Actions YAML

Choose GitLab CE when:

You need the all-in-one platform and your team is large enough to justify its operational weight. Concretely: if you are running 10+ engineers who all need CI, a container registry, security scanning reports, issue tracking, and environment management without standing up and maintaining separate tools — Gitea Actions + Harbor + Trivy + Linear — GitLab’s integration cost starts to amortize. If you are working in a regulated environment (SOC 2, ISO 27001) where audit logs, compliance pipelines, and a unified vulnerability record matter, GitLab’s EE tier is purpose-built for those workflows. If you already have an 8–16 GB VM to spare and your team already knows GitLab from a corporate context, the familiarity alone justifies it.

Choose Forgejo when:

You are a solo operator, a small team, or running on resource-constrained hardware. Forgejo on a 1 GB VPS with SQLite is a completely legitimate production git host for most homelab and small-project needs. Forgejo Actions covers the majority of CI use cases with familiar GitHub Actions syntax. Adding a separate CI solution — like Woodpecker CI, which runs well alongside Forgejo — keeps each component independently upgradeable and auditable without coupling your git hosting to your pipeline system. The operational simplicity of a Go binary that you upgrade by dropping a new file in is not a consolation prize; it is a genuine operational advantage.


Verdict

GitLab CE is an excellent platform if you can honestly provision it and maintain it. The integration story is real: $CI_REGISTRY_IMAGE in your pipeline just works, SAST templates require one include: line, and merge request pipelines with coverage visualization are genuinely useful for team workflows. You do not need to configure a separate registry, a separate package manager, or a separate secrets scanner — they are there, at no license cost.

But the weight is real too. You are committing to at minimum an 8 GB VM (realistically 12–16 GB for a comfortable team install), an upgrade discipline with required stops, a backup practice that explicitly manages secrets outside the backup archive, and an operational complexity that Forgejo simply does not have. If that weight is not justified by your actual use case — and for most homelab operators running personal projects or a small team, it is not — then Forgejo with an external CI runner is the right answer and you should not feel like you are settling.

The correct mental model: GitLab CE is infrastructure. You do not install it the way you install an app. You install it the way you install a database cluster — with a plan for sizing, backups, secrets management, and upgrades before the first user logs in.


Sources

Comments