Skip to content

28 January 2026

Self-Hosted GitLab CI/CD: Building My Own Deployment Platform

How I set up GitLab CE with automated pipelines on my homelab—and what I learned the hard way about SSL termination, reverse proxies, and infrastructure design.

When I decided to build my own CI/CD infrastructure, the easy path would have been GitHub Actions or GitLab.com's free tier. Instead, I went self-hosted. Here's why:

Enterprise relevance. Many organisations choose not put their code on the public internet. Financial services, healthcare, government, they all run internal GitLab instances. I wanted hands-on experience with the patterns I'd encounter professionally, not just the managed SaaS version.

No metering anxiety. I run commits 50+ times a day when iterating on a problem. With a self-hosted setup, I never think about CI minutes, runner limits, or surprise bills. The pipeline runs when I push. Every time.

Hardware I already own. I have an enterprise R730 server running Proxmox in my homelab. It has more headroom than I'll ever need. Why pay for cloud compute when the capacity is sitting in my home office?

Isolation for peace of mind. I experiment a lot. VMs get broken, rebuilt, wiped. By keeping GitLab on its own isolated VM, I know that even if I catastrophically break my other environments, my codebase survives. Belt-and-braces: nightly backups mean I'd never lose more than a day's work anyway.


Key takeaways

  • Mirror enterprise patterns with self-hosted GitLab.
  • Keep SSL termination at Traefik; run GitLab over HTTP internally.
  • Isolate runners to protect GitLab responsiveness.
  • Read error messages literally; they usually name the root cause.

The Architecture

┌─────────────────────────────────────────────────────────────────────┐
│                         Proxmox Host (R730)                         │
├─────────────────────────────────────────────────────────────────────┤
│                                                                     │
│  ┌─────────────┐    ┌─────────────┐    ┌─────────────────────────┐  │
│  │  GitLab VM  │    │  Runner VM  │    │  Other VMs              │  │
│  │  (isolated) │    │   (DinD)    │    │ (experiments, can break)│  │
│  └──────┬──────┘    └──────┬──────┘    └─────────────────────────┘  │
│         │                  │                                        │
│         └────────┬─────────┘                                        │
│                  ▼                                                  │
│         ┌───────────────┐                                           │
│         │    Traefik    │  ← SSL termination + routing              │
│         └───────┬───────┘                                           │
│                 │                                                   │
└─────────────────┼───────────────────────────────────────────────────┘
                  ▼
          https://gitlab.spinstate.dev

Tip: Scroll horizontally on smaller screens to see the full diagram.

Key Design Decisions

  • GitLab on a dedicated VM — Isolation from experimental workloads that might break.
  • Runners on a separate VM — Resource isolation; GitLab stays responsive during heavy builds.
  • Docker-in-Docker executor — Container-based builds with clean environments each run.
  • Traefik for SSL termination — Already part of my infrastructure; no need to duplicate certificate management.
  • Nightly VM backups — Never lose more than a day; peace of mind for experimentation.

The Troubleshooting Journey

Setting this up wasn't a smooth path. Here's where I went wrong—and what I learned.

Problem 1: The Let's Encrypt Trap

I started with GitLab's official installation for Ubuntu:

sudo EXTERNAL_URL="https://gitlab.spinstate.dev" apt-get install gitlab-ce

After correcting my external URL and running gitlab-ctl reconfigure, I hit this:

FATAL: RuntimeError: letsencrypt_certificate[gitlab.spinstate.dev] had an error
DNS problem: NXDOMAIN looking up A for gitlab.spinstate.dev

The realisation: GitLab saw my HTTPS URL and automatically tried to provision a Let's Encrypt certificate. But this is local-only infrastructure—Let's Encrypt requires public DNS validation, which doesn't apply to my homelab.

Key learning: When you specify https:// in external_url, GitLab assumes you want it to handle SSL via Let's Encrypt unless you explicitly tell it otherwise.

Problem 2: Fighting My Own Infrastructure

My initial instinct was to make GitLab handle SSL directly. But I was missing the bigger picture: I already have a perfectly good SSL termination point—Traefik.

My homelab already includes:

  • Traefik reverse proxy managing all external traffic
  • Automated Let's Encrypt certificate provisioning
  • Wildcard certificates for *.spinstate.dev

Trying to make GitLab handle its own SSL was duplicating functionality and fighting against my existing architecture.

The Solution: Separation of Concerns

The correct approach separates responsibilities:

  • Traefik handles: HTTPS, SSL termination, certificate management, routing
  • GitLab handles: Application logic, Git operations, internal HTTP only

GitLab configuration (/etc/gitlab/gitlab.rb):

# External URL is what users see
external_url 'http://gitlab.spinstate.dev'

# GitLab listens on plain HTTP internally
nginx['listen_port'] = 80
nginx['listen_https'] = false

# Disable Let's Encrypt completely
letsencrypt['enable'] = false

# Tell GitLab it's behind an HTTPS proxy
nginx['proxy_set_headers'] = {
  "X-Forwarded-Proto" => "https",
  "X-Forwarded-Ssl" => "on"
}

Traefik configuration:

http:
  routers:
    gitlab:
      rule: "Host(`gitlab.spinstate.dev`)"
      service: gitlab-service
      entryPoints:
        - websecure
      tls:
        certResolver: letsencrypt

  services:
    gitlab-service:
      loadBalancer:
        servers:
          - url: "http://X.X.X.X:80"  # HTTP to GitLab obsfucated internal

Problem 3: Protocol Mismatch

Even after the above, I had one more mistake. My initial Traefik config had:

- url: "https://X.X.X.X:80"  # WRONG

I was telling Traefik to connect via HTTPS to a service listening on HTTP. Small typo, confusing errors.

Fixed:

- url: "http://X.X.X.X:80"  # Correct

The Request Flow

User Browser
    ↓ HTTPS
gitlab.spinstate.dev:443
    ↓
Traefik (SSL termination)
    ↓ HTTP
GitLab nginx (port 80)
    ↓
GitLab application

What This Enables

The payoff for this setup is simple but significant:

I just git push, and it's deployed.

No more SSH-ing into servers. No more remembering which script to run, or from which directory. I push to main, the pipeline runs, and my sites update. That's it.

Currently deploying through this pipeline:

  • spinstate.dev — my portfolio site
  • A community campaign website — pro bono project

Both deploy to nginx:alpine containers. Nothing fancy. Does the job.

The real value isn't the complexity of what I'm deploying—it's that I now understand the fundamentals of CI/CD from the infrastructure up, not just the YAML, as fun as that is.


Costs and Tradeoffs

Self-hosting buys control and realism, but it isn't free. You take on patching, monitoring, backups, storage growth, and the occasional weekend fix. And if your lab goes down, your pipelines do too. For me, the trade is worth it because the learning and autonomy outweigh the extra operational overhead.


Takeaways

  1. Understand your infrastructure layers. Don't duplicate functionality. If you already have SSL termination, use it.

  2. Read error messages carefully. The Let's Encrypt error explicitly mentioned DNS validation failure. That should have immediately signalled the mismatch with local-only infrastructure.

  3. Match protocols correctly. When configuring reverse proxies, the upstream URL protocol must match what the backend actually listens on.

  4. Use proxy headers appropriately. X-Forwarded-Proto and X-Forwarded-Ssl are crucial for applications to know they're behind an HTTPS proxy.

  5. Consult the community. The GitLab forums had threads on Traefik integration. The problem had been solved before.


Resources


What's Next

This setup works, but it's not finished. In Part 2, I'll cover the security side—specifically, secrets management. My initial approach had gaps that I've since corrected, and I'll walk through:

  • What I got wrong with credential handling
  • GitLab CI/CD variables and environment scoping
  • The path toward proper secrets management

Building your own CI/CD teaches you things that clicking "Enable GitHub Actions" never will. The troubleshooting was frustrating in the moment, but every error message was a lesson in how these systems actually work.


This post is part of my platform engineering portfolio. See more at spinstate.dev.

Comments load on request because GitHub may set cookies. See the privacy policy.