// BLOG

Self-Hosting GitLab: Registry, S3 Storage, and a Networking Gotcha

If you’ve read my earlier posts on Kubernetes and Traefik ingress, you know this homelab runs with production-grade tooling throughout. The same philosophy applies to source control and CI/CD: no GitHub, no managed pipelines, no external dependencies. Everything runs on hardware I own.

This post covers how the self-hosted GitLab deployment came together — including the container registry, MinIO as the S3 backend, the SSL setup, and one surprisingly annoying networking problem that took longer to diagnose than I’d like to admit.


How GitLab Is Deployed

GitLab runs as an Omnibus installation on a dedicated RHEL 9 VM (gitlab.blackburn.lan, 192.168.1.156). Omnibus is GitLab’s all-in-one package — it bundles the Rails application, Sidekiq workers, PostgreSQL, Redis, Gitaly (the Git storage layer), and NGINX all into a single managed service with one unified config file (/etc/gitlab/gitlab.rb). For a homelab, that’s the right call. Running each component separately is how you end up maintaining six different things instead of one.

The VM itself lives on TrueNAS as a macvtap virtual machine — bridged directly onto the LAN with its own IP address. It’s running RHEL 9, registered with Red Hat IDM for centralized authentication, and sized to run GitLab comfortably: 4 vCPUs, 8GB RAM, and dedicated SSD storage for the Git repositories.

LDAP integration routes through Red Hat IDM. Every user in the blackburn.lan domain can sign into GitLab with their domain credentials — no separate GitLab accounts to manage. GitLab talks to the IDM VIP (idm.blackburn.lan:389) over LDAP.


SSL: Traefik Handles Termination

GitLab’s Omnibus NGINX handles TLS natively, but in this environment all external TLS termination lives at Traefik. The pattern is consistent across every service in the stack: Traefik holds the wildcard cert for *.burnedworm.com (issued via cert-manager + Cloudflare DNS-01), terminates HTTPS externally, and proxies to the backend over HTTP or HTTPS depending on the service.

For GitLab specifically, there are two endpoints to consider:

  • Web UI and APIgitlab.burnedworm.com routes to port 80 on the VM (Omnibus NGINX). Traefik terminates the wildcard TLS and forwards plaintext internally.
  • Container Registryregistry.burnedworm.com routes to port 5050, where GitLab’s integrated registry listener sits.

The Omnibus config sets external_url to the public HTTPS address and registry_external_url separately, so GitLab generates correct clone URLs and registry pull paths even though it isn’t doing the TLS itself.

Git over SSH works directly — port 22 on the VM is exposed without proxying, since SSH handles its own transport security.


The Container Registry

GitLab ships with an integrated container registry out of the box. You enable it, point it at a storage backend, and GitLab wires up the authentication — every project automatically gets a registry path under registry.burnedworm.com/jdblackb/<project>. No separate registry credentials to manage; your GitLab token is your registry token.

By default the registry stores images on local disk. That works fine for small setups, but it means images live on the VM’s local storage, which isn’t backed up separately and doesn’t scale cleanly. The better option is to point the registry at an S3-compatible object store.

That’s where MinIO comes in.


MinIO on TrueNAS: Why It Made Sense

MinIO runs as a Docker container on TrueNAS, exposing an S3-compatible API on port 9000 (HTTPS) with the console on 9002. TrueNAS was already the NAS backbone for the homelab — it holds NFS shares for Kubernetes persistent volumes, media libraries, backups. Adding MinIO there was a natural fit: the data lives on the same hardware that already handles storage, backed by TrueNAS’s ZFS pool management and snapshot capabilities.

The MinIO bucket (gitlab-registry) stores all container image layers. GitLab’s registry just sees an S3 endpoint — it doesn’t know or care that it’s MinIO running on TrueNAS. From a storage perspective, the images get the same redundancy guarantees as everything else on the pool.

MinIO also ended up serving multiple consumers: the GitLab registry, and eventually Kubernetes workloads via the s3.k8s.burnedworm.com endpoint. Standing it up once and using it as a shared S3 layer proved more useful than it initially seemed.


The Networking Problem

This is the part that took longer than it should have.

GitLab (on its macvtap VM) needs to reach MinIO to store and retrieve registry images. MinIO runs on TrueNAS at 192.168.1.198. The GitLab config pointed at that IP directly. Simple enough — except it didn’t work.

macvtap networking has a fundamental constraint: a VM bridged via macvtap can reach other machines on the LAN, but it cannot reach the host it’s running on. The macvtap driver doesn’t allow traffic to loop back from a guest to the TrueNAS host IP. From GitLab’s perspective, 192.168.1.198 was simply unreachable.

The fix was indirect: instead of pointing GitLab at TrueNAS directly, route it through Traefik. MinIO was already exposed at s3.k8s.burnedworm.com via a Traefik IngressRoute, which terminates TLS and proxies to TrueNAS:9000. GitLab can reach the Traefik VIP (192.168.1.30) just fine, so https://s3.k8s.burnedworm.com works where https://192.168.1.198:9000 doesn’t.

The relevant Omnibus registry storage config:

gitlab_rails['registry_storage'] = {
  'storage' => {
    's3' => {
      'accesskey'      => 'gitlab',
      'secretkey'      => 'GitLabS3pass99',
      'regionendpoint' => 'https://s3.k8s.burnedworm.com',
      'bucket'         => 'gitlab-registry',
      'region'         => 'us-east-1',
      'pathstyle'      => true
    }
  }
}

The regionendpoint pointing at Traefik rather than TrueNAS directly is the key. Once that changed and Omnibus reconfigured, the registry came up cleanly and image pushes started landing in MinIO.


CI/CD Pipelines

With GitLab running and the registry working, the real payoff is the CI/CD layer. Every repository in this environment has a pipeline that handles its own deployment automatically on push to main.

The runner runs inside Kubernetes as a GitLab Runner pod in the gitlab-runner namespace, using the Kubernetes executor. Each pipeline job spins up a fresh Kubernetes pod, runs, and tears itself down. No persistent runner state, no Docker socket exposure.

Image builds use Kaniko. The traditional approach — Docker-in-Docker — requires a privileged container or a shared Docker socket, both of which are security compromises. Kaniko builds OCI images directly from a Dockerfile inside an unprivileged container, pushes to the registry, and exits. The build job looks roughly like:

build:
  image:
    name: gcr.io/kaniko-project/executor:v1.23.2-debug
    entrypoint: [""]
  script:
    - /kaniko/executor
        --context $CI_PROJECT_DIR
        --dockerfile $CI_PROJECT_DIR/Dockerfile
        --destination registry.burnedworm.com/$CI_PROJECT_PATH:$VERSION

Deployments use bitnami/kubectl with a kubeconfig assembled from three CI/CD variables — KUBE_URL, KUBE_TOKEN, and KUBE_CA_CERT — set at the instance level so every project can deploy to the cluster without duplicating credentials. The deploy job builds the kubeconfig in the pipeline script, runs kubectl apply, and exits:

deploy:
  image: docker.io/bitnami/kubectl:latest
  script:
    - echo "$KUBE_CA_CERT" > /tmp/ca.crt
    - kubectl config set-cluster homelab --server=$KUBE_URL --certificate-authority=/tmp/ca.crt
    - kubectl config set-credentials gitlab --token=$KUBE_TOKEN
    - kubectl config set-context homelab --cluster=homelab --user=gitlab
    - kubectl config use-context homelab
    - kubectl apply -f manifest.yml

Version injection happens via sed substitution at pipeline time, keeping manifests clean and the pipeline config as the single source of truth for what version gets deployed.

The Logstash pipeline is a good example of the pattern in practice: conf.d/ files tracked in Git with numeric prefixes (10-beats-input.conf, 50-filters.conf, 90-elastic-output.conf) are assembled into a ConfigMap by the CI pipeline at deploy time. The pipeline is the build system, not just a deployment trigger.


The Result

The full source control and delivery chain is self-hosted and owned end-to-end:

LayerSolution
Source controlGitLab Omnibus on RHEL 9
AuthenticationRed Hat IDM (LDAP)
TLSTraefik wildcard cert (cert-manager + Cloudflare DNS-01)
Container registryGitLab integrated registry
Registry storageMinIO on TrueNAS (S3-compatible)
CI runnerGitLab Runner (Kubernetes executor)
Image buildsKaniko (no Docker daemon)
Deploymentskubectl via kubeconfig from CI variables

Nothing here touches a managed service. GitLab knows when you push. The runner picks up the job. Kaniko builds the image. kubectl applies the manifest. The whole chain — from git push to running pod — completes in a few minutes with no human in the loop.


Jeremy Blackburn is a systems and infrastructure engineer and principal of Initech Advising LLC. He helps organizations modernize infrastructure and build reliable platforms at scale.