Selfhosted Gitlab Setup
A Beginner’s Journey to CI/CD: Understanding GitLab Self-Hosting Link to heading
I wanted to learn by doing. This isn’t a perfect guide; it’s more like my personal log of what I did, what worked, and maybe a few things I fumbled with.
Firstly, I set up two virtual machines using Proxmox : 🧠 One VM for the GitLab server ⚙️ Another VM just for GitLab Runners
Why two? I figured keeping the runners separate from the main GitLab instance would help me troubleshoot things more easily later on.
The Groundwork: Prepping the VM Link to heading
sudo apt update && sudo apt upgrade -y
sudo apt install -y htop net-tools vim nano ufw
## Docker Setup
sudo apt install -y ca-certificates curl gnupg lsb-release
# Add Docker’s GPG key
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpg
# Add Docker repository
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" \
| sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
# Update and install Docker
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
# Enable and start Docker service
sudo systemctl enable docker
sudo systemctl start docker
# Quick test to make sure Docker is alive
sudo docker run hello-world
# Add your user to the docker group so you don't have to sudo all the time
sudo usermod -aG docker $USER
newgrp docker # Apply group changes immediately
Structuring for GitLab (and its data) Link to heading
mkdir gitlab
cd gitlab
mkdir data
mkdir config
mkdir logs
mkdir -p gitlab-runner1/config
mkdir -p gitlab-runner2/config
mkdir -p gitlab-runner3/config
I created data
, config
, and logs
for the main GitLab instance, and then separate config
directories for each of my three runners. Why three? Because why not have options for parallel jobs!
Docker Compose for Gitlab Link to heading
docker-compose.yaml
file for the GitLab instance and three runners.
services:
gitlab:
image: gitlab/gitlab-ce:latest
container_name: gitlab
restart: unless-stopped
hostname: 'gitlab.local' # Internal hostname for the container
environment:
GITLAB_OMNIBUS_CONFIG: |
external_url 'http://YOUR_SERVER_IP' # IMPORTANT: Replace with your server’s actual IP or domain!
nginx['listen_port']=80
nginx['listen_https']=false
ports:
- '80:80' # HTTP access to GitLab UI
- '22:22' # Git over SSH access
volumes:
- './config:/etc/gitlab' # GitLab configuration files
- './logs:/var/log/gitlab' # GitLab logs
- './data:/var/opt/gitlab' # GitLab data (repositories, databases, etc.)
gitlab-runner1:
image: gitlab/gitlab-runner:latest
container_name: gitlab-runner1
restart: unless-stopped
depends_on:
- gitlab # Ensure GitLab is up before starting runners
volumes:
- ./gitlab-runner1/config:/etc/gitlab-runner # Runner-specific configuration
- /var/run/docker.sock:/var/run/docker.sock # Allows runner to use Docker executor
gitlab-runner2:
image: gitlab/gitlab-runner:latest
container_name: gitlab-runner2
restart: unless-stopped
depends_on:
- gitlab
volumes:
- ./gitlab-runner2/config:/etc/gitlab-runner
- /var/run/docker.sock:/var/run/docker.sock
gitlab-runner3:
image: gitlab/gitlab-runner:latest
container_name: gitlab-runner3
restart: unless-stopped
depends_on:
- gitlab
volumes:
- ./gitlab-runner3/config:/etc/gitlab-runner
- /var/run/docker.sock:/var/run/docker.sock
Note: Make sure you replace http://YOUR_SERVER_IP with your VM’s actual IP address or a domain name if you’re using one. Otherwise, GitLab gets confused about its own URL, and things get weird.
Firing It Up!
docker compose up -d
Getting That Initial Root Password
Once GitLab is up and running (you can check docker logs gitlab for progress), you’ll need the initial root password to log in.
docker exec -it gitlab cat /etc/gitlab/initial_root_password | grep -i "password:"
This command dives into the GitLab container, finds the initial password file, and filters for the password line. Copy that bad boy down!
Registering the Runners Link to heading
Instead of manually registering each runner via the UI, I wrote a quick script. First, you need the Runner Registration Token from your GitLab UI.
Log in as root → GitLab UI → Admin → Overview → Runners → Registration token Grab that token!
register_runners.sh
:
#!/bin/bash
# GitLab config
GITLAB_URL="http://YOUR_SERVER_IP/" # Your GitLab URL
REGISTRATION_TOKEN="YOUR_REGISTRATION_TOKEN" # Your actual registration token!
DEFAULT_IMAGE="alpine:latest" # Default Docker image for jobs
# List of runner container names
RUNNERS=("gitlab-runner1" "gitlab-runner2" "gitlab-runner3")
# Register each runner
for RUNNER in "${RUNNERS[@]}"; do \
echo "Registering runner: $RUNNER";
docker exec -it "$RUNNER" gitlab-runner register --non-interactive \
--url "$GITLAB_URL" \
--registration-token "$REGISTRATION_TOKEN" \
--executor "docker" \
--docker-image "$DEFAULT_IMAGE" \
--description "$RUNNER" \
--tag-list "" \
--run-untagged="true" \
--locked="false"
done
echo "✅ All runners registered."
Remember to replace GITLAB_URL
and REGISTRATION_TOKEN
with your actual values!
Make it executable and run it:
chmod +x register_runners.sh
./register_runners.sh
All your runners should now appear in the GitLab UI under “Admin -> Overview -> Runners” as active.
My First CI/CD Pipeline: .gitlab-ci.yml
Link to heading
With GitLab and runners ready, it was time for the fun part: defining a pipeline. I created a simple .gitlab-ci.yml
file in a test project. This file tells GitLab what to do when code changes are pushed.
stages:
- test
- build
test_alpine:
stage: test
image: alpine:latest
script:
- echo "Testing on Alpine"
- cat /etc/os-release
- uname -a
test_ubuntu:
stage: test
image: ubuntu:22.04
script:
- echo "Testing on Ubuntu"
- cat /etc/os-release
- uname -a
build_node:
stage: build
image: node:20
script:
- echo "Running build with Node.js"
- node --version
- npm --version
test_python:
stage: test
image: python:3.11
script:
- echo "Testing with Python"
- python --version
- pip --version
test_default_os:
stage : build # Or 'test', depending on what you want to check
script:
- lsb_release -a # <-- This will throw an error
This pipeline has two stages: test
and build
.
As highlighted in the pipeline yaml file, it failed because the script tried to run the lsb_release -a command, which is not available in the alpine:latest Docker image.
Once you’ve got this .gitlab-ci.yml file in your project’s root directory, you need to commit it and push it to your GitLab repository (e.g., to the master or main branch). This is what tells GitLab to pick up the pipeline and start running jobs!
test_alpine
and test_ubuntu
run simple commands on different OS images. This is great for checking compatibility.
build_node
uses a Node.js image to simulate a build process.
test_python
does the same for Python.
test_default_os
just checks the OS of the default runner image (which I set to alpine:latest
during registration).
Every time I push code to this project, GitLab automatically picks up this file, and the runners get to work, executing these jobs.
Wrapping Up Link to heading
This whole setup helps with connecting the dots — seeing how all the moving pieces fit together: 👉 the GitLab server 👉 the runners 👉 the pipeline config 👉 the actual job logs and artifacts
After getting everything up and running, this would help in understanding the “how” and “why” behind CI/CD. Even if it’s not all crystal clear yet, it sets a stronger foundation now.
Here are some questions you should be able to answer now:
- What actually happens behind the scenes when a pipeline starts?
- How do GitLab Runners (agents) communicate with the GitLab instance?
- What triggers a pipeline automatically vs manually?
- What happens if we don’t specify an image in .gitlab-ci.yml?
- What are the different executor types (Shell, Docker, Kubernetes), and when to use which?
- Where do logs and job outputs go, and how are they stored?
The answers are slowly unfolding — not through a crash course, but by actually doing it.
Happy automating!