Running a Lightweight GitLab CE Instance on TrueNAS

I already use Forgejo as my primary Git service in the homelab.

It runs on my PROD TrueNAS system, is lightweight, does everything I normally need from a Git forge and integrates nicely with Forgejo Actions. Still, I wanted a GitLab instance as well.

Not because I wanted to replace Forgejo, but because GitLab is widely used in enterprise environments and I wanted a place where I can test GitLab CI/CD, runners, projects and administration without depending on an external service. The problem is that GitLab Omnibus is considerably heavier than Forgejo.

The GitLab instance should remain available, but it should not consume excessive CPU, RAM or storage I/O while sitting idle. In this article, i document my setup.

I wanted GitLab CE to:

  • run as a TrueNAS Custom App
  • use persistent datasets
  • be accessible through my existing Nginx Proxy Manager
  • support Git over SSH
  • support normal GitLab CI/CD and runners
  • consume as little memory and CPU as reasonably possible
  • avoid unnecessary background monitoring services
  • avoid unnecessary disk writes
  • remain upgradeable and understandable

This is not meant to become my primary Git platform as Forgejo still fills that role, so i’ve placed gitlab on my DR Site.

The Architecture

The final setup looks like this:

                     Client
                       |
                       |
                 https://gitlab.lab
                       |
                       v
              +------------------+
              | Nginx Proxy      |
              | Manager          |
              | TLS termination  |
              +--------+---------+
                       |
                       | HTTP :8088
                       v
              +------------------+
              | TrueNAS DR       |
              |                  |
              | GitLab CE 19.4.1 |
              +--------+---------+
                       |
         +-------------+--------------+
         |             |              |
         v             v              v
    PostgreSQL       Redis          Gitaly
                                      |
                                      v
                                   Git Repos

SSH:
Client -> TrueNAS:2222 -> GitLab:22

TLS is handled by Nginx Proxy Manager.

GitLab itself listens internally on HTTP.

The externally visible URL however remains:

https://gitlab.lab

This is important because GitLab uses its external URL when generating clone URLs, redirects and other links.

Persistent Storage

I use three normal TrueNAS datasets for GitLab:

/mnt/ssd-pool/apps/gitlab/config
/mnt/ssd-pool/apps/gitlab/logs
/mnt/ssd-pool/apps/gitlab/data

They are mounted into:

/etc/gitlab
/var/log/gitlab
/var/opt/gitlab

This keeps the GitLab configuration, logs, database and repositories outside of the container.

The container itself can therefore be recreated without losing the installation.

The mapping is:

TrueNAS

/mnt/ssd-pool/apps/gitlab/
|
+-- config  -> /etc/gitlab
+-- logs    -> /var/log/gitlab
`-- data    -> /var/opt/gitlab

GitLab Needs Some Tuning!

A default GitLab Omnibus installation starts considerably more than just a web application.

Depending on configuration it can include services such as:

Puma
Sidekiq
PostgreSQL
Redis
Gitaly
GitLab Workhorse
Nginx
Prometheus
Alertmanager
GitLab Exporter
Node Exporter
PostgreSQL Exporter
Redis Exporter
GitLab KAS
Container Registry
Pages
...

That makes sense for a full GitLab installation, but for my homelab test instance a large part of this is unnecessary. I do not need GitLab to run its own Prometheus stack nor do i need GitLab Pages. I do not need its Container Registry <– or do i ???? …. need to revisit this later 😮

The idea was therefore to keep the GitLab components I actually need:

Gitaly
GitLab Workhorse
Logrotate
Nginx
PostgreSQL
Puma
Redis
Sidekiq
SSHD

and disable the rest where possible.

The Final TrueNAS YAML

I installed GitLab using:

Apps
    -> Discover Apps
        -> Install via YAML

The final configuration is:

services:
  gitlab:
    image: gitlab/gitlab-ce:19.4.1-ce.0
    container_name: gitlab-ce
    hostname: gitlab.lab
    restart: unless-stopped

    environment:
      GITLAB_PRODUCT_USAGE_DATA_ENABLED: "false"

      GITLAB_OMNIBUS_CONFIG: |
        # ============================================================
        # BASIC CONFIGURATION
        # ============================================================

        external_url 'https://gitlab.lab'

        letsencrypt['enable'] = false

        gitlab_rails['nginx']['listen_port'] = 80
        gitlab_rails['nginx']['listen_https'] = false

        gitlab_rails['gitlab_shell_ssh_port'] = 2222


        # ============================================================
        # LOW RESOURCE CONFIGURATION
        # ============================================================

        puma['worker_processes'] = 0
        puma['min_threads'] = 1
        puma['max_threads'] = 4

        sidekiq['concurrency'] = 5


        # ============================================================
        # MONITORING SERVICES DISABLED
        # ============================================================

        prometheus_monitoring['enable'] = false
        prometheus['enable'] = false
        alertmanager['enable'] = false

        gitlab_exporter['enable'] = false
        node_exporter['enable'] = false
        postgres_exporter['enable'] = false
        redis_exporter['enable'] = false

        puma['exporter_enabled'] = false
        sidekiq['metrics_enabled'] = false


        # ============================================================
        # UNUSED SERVICES
        # ============================================================

        gitlab_kas['enable'] = false
        gitlab_pages['enable'] = false
        registry['enable'] = false


        # ============================================================
        # UNUSED FEATURES
        # ============================================================

        gitlab_rails['packages_enabled'] = false
        gitlab_rails['dependency_proxy_enabled'] = false
        gitlab_rails['lfs_enabled'] = false
        gitlab_rails['terraform_state_enabled'] = false
        gitlab_rails['ci_secure_files_enabled'] = false


        # ============================================================
        # EMAIL
        # ============================================================

        gitlab_rails['gitlab_email_enabled'] = false


        # ============================================================
        # TELEMETRY
        # ============================================================

        gitlab_rails['usage_ping_enabled'] = false
        gitlab_rails['usage_ping_generation_enabled'] = false
        gitlab_rails['include_optional_metrics_in_service_ping'] = false


        # ============================================================
        # POSTGRESQL
        # ============================================================

        postgresql['shared_buffers'] = "128MB"


        # ============================================================
        # REDIS
        # ============================================================

        redis['save'] = [
          '900 1'
        ]


        # ============================================================
        # RAILS / SIDEKIQ ENVIRONMENT
        # ============================================================

        gitlab_rails['env'] = {
          'MALLOC_CONF' => 'dirty_decay_ms:1000,muzzy_decay_ms:1000',
          'GITLAB_LOG_LEVEL' => 'WARN',
          'SIDEKIQ_LOG_ARGUMENTS' => '0',
          'GITLAB_PRODUCT_USAGE_DATA_ENABLED' => 'false'
        }


        # ============================================================
        # GITALY
        # ============================================================

        gitaly['configuration'] = {
          logging: {
            level: "warn"
          }
        }

        gitaly['env'] = {
          'MALLOC_CONF' => 'dirty_decay_ms:1000,muzzy_decay_ms:1000',
          'GITALY_COMMAND_SPAWN_MAX_PARALLEL' => '2'
        }


    ports:
      - "8088:80"
      - "2222:22"

    volumes:
      - /mnt/ssd-pool/apps/gitlab/config:/etc/gitlab
      - /mnt/ssd-pool/apps/gitlab/logs:/var/log/gitlab
      - /mnt/ssd-pool/apps/gitlab/data:/var/opt/gitlab

    shm_size: "256m"

    healthcheck:
      test:
        - CMD-SHELL
        - curl -fsS http://localhost/-/health || exit 1
      interval: 5m
      start_interval: 30s
      timeout: 5s
      retries: 3
      start_period: 5m

    deploy:
      resources:
        limits:
          cpus: "2.0"
          memory: 5G


x-portals:
  - host: gitlab.lab
    name: Web UI
    path: /
    port: 443
    scheme: https

Puma Single Mode

One of the largest memory savings comes from Puma! Instead of multiple Puma worker processes I use:

puma['worker_processes'] = 0
puma['min_threads'] = 1
puma['max_threads'] = 4

This starts Puma in single mode.

Puma starting in single mode...

Min threads: 1
Max threads: 4

Reducing Sidekiq

GitLab uses Sidekiq for background processing. Completely disabling it is not an option because many GitLab functions depend on it. Instead I reduced its concurrency:

sidekiq['concurrency'] = 5


And the GitLab system check shows exactly one Sidekiq process:
Checking Sidekiq ...

Sidekiq: ... Running? ... yes
Number of Sidekiq processes (cluster/worker) ... 1/1

Disabling GitLab Monitoring

TrueNAS and my existing monitoring infrastructure already cover the host and container level, so i could disalbe:

Prometheus
Alertmanager
GitLab Exporter
Node Exporter
PostgreSQL Exporter
Redis Exporter

After reconfiguring GitLab, gitlab-ctl status shows only:

run: gitaly
run: gitlab-workhorse
run: logrotate
run: nginx
run: postgresql
run: puma
run: redis
run: sidekiq
run: sshd

There is no separate Prometheus or exporter process running in the final instance.

Disabling Services I Do Not Need

I also disabled several GitLab services and features.

For example:

gitlab_kas['enable'] = false
gitlab_pages['enable'] = false
registry['enable'] = false

And features such as:

Package Registry
Dependency Proxy
Git LFS
Terraform State
CI Secure Files

This is an important distinction – I did not disable GitLab CI/CD itself.

Manual pipelines and external GitLab runners remain part of the reason this instance exists.

No Email

Initially GitLab attempted to deliver email through:

/usr/sbin/sendmail

There is no mail infrastructure configured inside this container, so every such attempt resulted in a failed Sidekiq mail job.

For this private test instance I simply disabled email:

gitlab_rails['gitlab_email_enabled'] = false

This means I cannot rely on GitLab for things such as password-reset or notification email.

That is fine for this environment because accounts are administered manually.

More importantly, GitLab no longer creates pointless mail jobs that are guaranteed to fail.

Disabling Product Usage Data

Another interesting source of unnecessary activity was product usage telemetry.

Before disabling it, the logs regularly contained entries such as:

product_usage_data.log
sending event

followed by failed attempts to contact:

events.gitlab.net

I initially set:

GITLAB_PRODUCT_USAGE_DATA_ENABLED: "false"

at the Docker environment level.

To make sure the setting also reaches Rails itself, I additionally added it to:

gitlab_rails['env']

The resulting configuration contains:

gitlab_rails['env'] = {
  'MALLOC_CONF' => 'dirty_decay_ms:1000,muzzy_decay_ms:1000',
  'GITLAB_LOG_LEVEL' => 'WARN',
  'SIDEKIQ_LOG_ARGUMENTS' => '0',
  'GITLAB_PRODUCT_USAGE_DATA_ENABLED' => 'false'
}

The setting can be verified directly inside Rails:

sudo docker exec -it gitlab-ce \
  gitlab-rails runner \
  "puts ENV.fetch('GITLAB_PRODUCT_USAGE_DATA_ENABLED', 'NOT_SET')"

The result:

false

After the change I no longer saw new product usage events being generated.

Reducing Redis Disk Activity

Redis persistence was another small source of regular writes.

The default configuration can create snapshots based on several change thresholds.

For this low-priority instance I changed it to:

redis['save'] = [
  '900 1'
]

This means Redis creates an RDB snapshot after 900 seconds if at least one change occurred.

The resulting Redis log showed:

1 changes in 900 seconds. Saving...
Background saving started
DB saved on disk
Background saving terminated with success

Redis persistence remains enabled, but I avoid snapshots every few minutes while the GitLab instance is otherwise idle.

A Lightweight Healthcheck

The original healthcheck was another surprising source of work.

A request against a normal Rails page such as:

/help

does much more than simply check whether the service is alive.

Instead I use GitLab’s lightweight health endpoint:

/-/health

The Docker healthcheck is therefore:

healthcheck:
  test:
    - CMD-SHELL
    - curl -fsS http://localhost/-/health || exit 1
  interval: 5m
  start_interval: 30s
  timeout: 5s
  retries: 3
  start_period: 5m

During startup Docker checks more frequently.

Once GitLab is healthy, the check runs every five minutes.

The resulting requests take only a few milliseconds:

GET /-/health
status: 200
duration: 2ms

Resource Limits

I deliberately did not try to force GitLab into an unrealistically small memory limit.

The container is allowed:

deploy:
  resources:
    limits:
      cpus: "2.0"
      memory: 5G

These are maximum limits

After all tuning was applied the measured memory usage was:

CPU=1.26%
RAM=2.149GiB / 5GiB
BLOCK=23.8GB / 0B

A minute later:

CPU=13.51%
RAM=2.148GiB / 5GiB
BLOCK=23.8GB / 0B
RAM remained around 2.15 GiB and BLOCK I/O did not change

For a complete GitLab Omnibus installation including PostgreSQL, Redis, Sidekiq and Gitaly, roughly 2.1 GB idle memory is a result I am quite happy with!

Checking TrueNAS Pool Activity

Docker only shows the container side.

I also checked the underlying SSD pool:

sudo zpool iostat -v ssd-pool 5

The idle samples looked approximately like this:

operations       bandwidth
read  write      read  write

0      25          0   169K
0      30          0   164K
0      28          0   142K
0      17          0   103K

This is the entire ssd-pool, not GitLab alone.

Several applications live on this pool.

Still, roughly 100-170 KB/s of total background writes is sufficiently small for my purposes.

Together with the unchanged GitLab Docker Block I/O counter it strongly suggests that GitLab itself is not continuously hammering the storage while idle.

Validating the Installation

After the final deployment I checked all GitLab services:

sudo docker exec -it gitlab-ce gitlab-ctl status

The result:

run: gitaly
run: gitlab-workhorse
run: logrotate
run: nginx
run: postgresql
run: puma
run: redis
run: sidekiq
run: sshd

Next I checked Gitaly:

sudo docker exec -it gitlab-ce \
  gitlab-rake gitlab:gitaly:check

Result:

Checking Gitaly ...

Gitaly: ... default ... OK

Checking Gitaly ... Finished

And finally the complete GitLab check:

sudo docker exec -it gitlab-ce \
  gitlab-rake gitlab:check SANITIZE=true

Among other things this confirmed:

GitLab Shell ... OK
Internal API available ... OK
Redis available via internal API ... OK

Gitaly ... default ... OK

Sidekiq ... Running? ... yes
Number of Sidekiq processes ... 1/1

The Gitaly Configuration Validator

One test produced an interesting result.

Running:

sudo docker exec -it gitlab-ce sh -lc \
  '/opt/gitlab/embedded/bin/gitaly configuration validate < /var/opt/gitlab/gitaly/config.toml'

returned:

{
  "errors": [
    {
      "key": [
        "gitlab",
        "url"
      ],
      "message":
        "parse \"http+unix://%2Fvar%2Fopt%2Fgitlab%2Fgitlab-workhorse%2Fsockets%2Fsocket\":
         invalid URL escape \"%2F\""
    }
  ]
}

At first this looked like a broken Gitaly configuration.

However, this URL is generated automatically by the GitLab Omnibus configuration.

More importantly, the functional Gitaly check reports:

Gitaly: ... default ... OK

and GitLab itself works normally.

I therefore deliberately did not edit:

/var/opt/gitlab/gitaly/config.toml

manually.

Generated Omnibus configuration should remain generated configuration.

For me the functional GitLab and Gitaly checks are more relevant than attempting to fix an automatically generated URL by hand.

Nginx Proxy Manager

My Nginx Proxy Manager configuration is simple:

Domain:
gitlab.lab

Scheme:
http

Forward Host:
TrueNAS DR

Forward Port:
8088

SSL:
enabled

Force SSL:
enabled

Websocket Support:
enabled

GitLab itself still uses:

external_url 'https://gitlab.lab'

because this describes the URL seen by the client.

The path is therefore:

Browser
    |
    | HTTPS
    v
Nginx Proxy Manager
    |
    | HTTP
    v
TrueNAS :8088
    |
    v
GitLab nginx :80

Why I Pin the GitLab Version

I do not use:

image: gitlab/gitlab-ce:latest

for this container.

Instead:

image: gitlab/gitlab-ce:19.4.1-ce.0

GitLab upgrades can contain database migrations and sometimes require specific upgrade paths.

An automatic image update is therefore not something I want on this instance.

Updates will be deliberate:

Check release
    |
    v
Check upgrade path
    |
    v
Backup
    |
    v
Change image tag
    |
    v
Redeploy
    |
    v
Run GitLab checks

This is one application where convenience is less important than knowing exactly when an upgrade happens.

The Final Result

The resulting GitLab instance currently looks like this:

GitLab CE 19.4.1

RAM idle:
~2.1 GiB

Maximum RAM:
5 GiB

Maximum CPU:
2 CPUs

Puma:
single process

Puma threads:
1 - 4

Sidekiq concurrency:
5

Prometheus:
disabled

Exporters:
disabled

KAS:
disabled

GitLab Pages:
disabled

Container Registry:
disabled

Mail:
disabled

Product telemetry:
disabled

Redis persistence:
15-minute threshold

Healthcheck:
5-minute interval

The important part is not reaching the lowest possible RAM number.

I could probably disable or restrict more components and save another few hundred megabytes.

But at some point the result would stop being a useful GitLab instance.

The target was:

Keep the GitLab functionality I want.

Remove services I do not need.

Avoid unnecessary background work.

Leave enough resources for GitLab to function normally.

At roughly 2.1 GB idle RAM and almost no measurable container block I/O while idle, that goal has been reached.

Final Thoughts

GitLab will probably never be as lightweight as Forgejo and that is fine.

They are different products with different scopes.