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.