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.

Registered Runners !

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. Pipeline In-Progress

Pipeline Runs !

Why did the pipeline fail ? 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!