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
-
Understand your infrastructure layers. Don't duplicate functionality. If you already have SSL termination, use it.
-
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.
-
Match protocols correctly. When configuring reverse proxies, the upstream URL protocol must match what the backend actually listens on.
-
Use proxy headers appropriately.
X-Forwarded-ProtoandX-Forwarded-Sslare crucial for applications to know they're behind an HTTPS proxy. -
Consult the community. The GitLab forums had threads on Traefik integration. The problem had been solved before.
Resources
- GitLab Omnibus configuration reference
- GitLab HTTPS configuration and reverse proxies
- Traefik dynamic configuration (routers/services)
- Let's Encrypt DNS validation
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.