Skip to content

GitLab CI vs GitHub Actions

An in-depth comparison of GitLab CI and GitHub Actions — covering pipelines, runners, and key features of both CI/CD platforms.

September 16, 2024

published

14 min

reading time

In this article, I’ll go in depth on the very popular CI/CD tools GitLab CI and GitHub Actions. Not just from the pipeline perspective, but also runners and other features.

GitLab vs GitHub

GitHub and GitLab are tools and platforms for hosting Git repositories, and today they both offer solutions for automating application testing and deployment, i.e., CI/CD. GitHub is currently the best-known and most popular platform and has become practically the home for nearly all open-source projects.

Probably the biggest difference is that GitHub is closed-source and a self-hosted instance is only available through GitHub Enterprise, while GitLab is open-source and can be hosted on your own server without needing a license, though you can purchase one for additional features.

GitHub offers paid features for individuals and companies (teams). Meanwhile, GitLab’s paid licenses are aimed only at companies (though nothing stops you from getting a license for yourself).

But that’s not all these platforms offer today. Both GitHub and GitLab provide services for hosting packages (NPM, Maven, Pip, NuGet, …) as well as OCI images (Docker images). And other DevOps tools/integrations. But I’ll either mention these features only briefly or not at all, saving them for a future article.

Today another very popular GitHub service is GitHub Copilot, an AI tool that helps you write code. It’s like your coding partner. It sometimes makes things up, but it can save you work. GitLab came out roughly a year later with a similar tool, GitLab Duo Chat.

One last piece of information that may be important to some: GitHub is owned by Microsoft, while GitLab is an independent company, publicly traded on the stock exchange (Nasdaq: GTLB).

What Is CI/CD?

CI/CD stands for Continuous Integration/Continuous Deployment (or Continuous Delivery). It is the process of automatically testing and deploying code to production. CI/CD is important for rapid application development and deployment.

CI is an essential part of the DevOps mindset — it automates and ensures code quality on every change pushed to the repository. At the same time, it gives us near-instant feedback on the state of the code.

CI today typically includes:

  • running tests
  • code quality checks
  • application builds

CD then handles:

  • deploying the application to environments (development, test, staging, production, …)

Continuous Deployment vs Continuous Delivery

These are two very similar terms that are often used interchangeably. But sometimes they mean different things.

Honestly, it doesn’t matter much — what matters is agreeing on whether both terms mean the same thing to you or not. If they mean something different, the most common distinction I encounter is that one means fully automated deployment and the other requires manual approval (e.g., clicking a button) before deployment proceeds.

GitLab CI

The fundamental building blocks of GitLab CI are:

  • pipeline - defines when it runs and what the default values are (defaults)
  • stage - a phase in which jobs run
  • job - a single task within a stage
  • include (templates) - reusable pipeline parts, in different files, repositories, or even GitLab instances

A pipeline is always defined in the .gitlab-ci.yml file in the root directory of the repository. But you can then reference other files either in the same repository or another. Or even on a different GitLab instance.

GitLab CI Examples

Let’s look at a simple example, for a JavaScript application:

# .gitlab-ci.yml

defaults:
  image: node:lts

stages:
  - install
  - test
  - build
  - deploy

install:
  stage: install
  script:
    - npm ci
  # cache the node_modules folder for subsequent jobs in this pipeline
  cache:
    paths:
      - node_modules/
    # cache is invalidated when the package-lock.json file changes
    key:
      files:
        - package-lock.json

test:
  stage: test
  script:
    - npm run test

build:
  stage: build
  script:
    - npm run build

deploy:
  stage: deploy
  script:
    - ./scripts/deploy.sh

Such a pipeline is beautifully simple, but in the real world it won’t be enough. For example, deploying to different environments — say to a staging environment from the main branch and to production from a release branch.

For conditional job execution we can use rules:

# .gitlab-ci.yml
# ...

deploy-staging:
  stage: deploy
  script:
    - ./scripts/deploy.sh staging
  rules:
    - if: $CI_COMMIT_BRANCH == "main"

deploy-production:
  stage: deploy
  script:
    - ./scripts/deploy.sh production
  rules:
    - if: $CI_COMMIT_BRANCH == "release"

Another useful feature is before_script:

# .gitlab-ci.yml
# ...

before_script:
  - echo "Hello, world!"

test:
  stage: test
  script:
    - npm run test

before_script runs before every job in the pipeline when defined globally. But it can also be defined for specific jobs only. If defined both globally and locally, only the local definition is used.

The last thing we’ll look at is working with Docker:

# .gitlab-ci.yml

defaults:
  image: docker:20.10

services:
  - docker:20.10-dind

stages:
  - build

build:
  stage: build
  variables:
    DOCKER_HOST: tcp://docker:2375/
    IMAGE: $CI_REGISTRY_IMAGE
    IMAGE_TAG: $CI_COMMIT_REF_SLUG-$CI_COMMIT_SHORT_SHA
  before_script:
    - echo $CI_REGISTRY_PASSWORD | docker login -u $CI_REGISTRY_USER --password-stdin $CI_REGISTRY
    - docker info
  script:
    - docker buildx create --name $CI_PROJECT_NAME-builder --use
    - docker buildx build --provenance=false --platform linux/amd64,linux/arm64 -t $IMAGE:$IMAGE_TAG --push .
    - docker manifest inspect $IMAGE:$IMAGE_TAG

Or you can configure the Runner to have direct access to the Docker socket, but that’s a security risk.

I won’t go into more detail in this article — covering everything would fill a whole series of articles.

If you’re interested in learning more about GitLab CI without reading hours of documentation, come to my GitLab CI training. Note: I’m still preparing it.

Templates

GitLab offers a list of pre-made pipelines (templates), but this approach to code reuse is outdated and is being replaced by the CI/CD Catalog.

You can find the CI/CD Catalog on any GitLab version 17.0.0 and above under Explore -> CI/CD Catalog. Or at the URL your-gitlab.com/explore/catalog.

You can then add a component from the catalog to your pipeline:

include:
  - component: $CI_SERVER_FQDN/my-org/security-components/[email protected]
    inputs:
      stage: build

Thanks to inputs, GitLab gets closer to GitHub Actions in terms of code reusability across projects.

If you’re interested in the CI/CD Catalog, check out the interactive beta demo.

GitLab Runner

GitLab Runner is an open-source project written in Go that can run on Linux, Windows, and macOS.

Probably the easiest way to run a Runner is in Docker. GitLab Runner can be started in Docker with a single command:

docker run -d \
    --name gitlab-runner \
    --restart always \
    -v /etc/gitlab-runner:/etc/gitlab-runner \
    -v /var/run/docker.sock:/var/run/docker.sock \
    -v /builds:/builds \
    -v /etc/hosts:/etc/hosts \
    gitlab/gitlab-runner:latest

Then you need to register the Runner with GitLab (whether GitLab.com or your own GitLab instance). I use a shell script like this (register-runner.sh).

It requires 2 arguments, with an optional third for the runner name, e.g., ./register-runner.sh https://gitlab.com/ my-runner-token blog-runner-01:

#!/usr/bin/env bash

if [ $# -z 2 ]; then
  echo "[ERROR] At least two arguments must be supplied, #1 is GitLab URl, #2 is GitLab Runner Token, #3 and optional is GitLab Runner Name"
  exit 1
fi

GITLAB_URL=$1
GITLAB_RUNNER_TOKEN=$2
GITLAB_RUNNER_NAME=${3:-$(hostname)}

docker exec gitlab-runner gitlab-runner register \
   --non-interactive \
   --url ${GITLAB_URL} \
   --registration-token ${GITLAB_RUNNER_TOKEN} \
   --name ${GITLAB_RUNNER_NAME} \
   --executor docker \
   --docker-pull-policy if-not-present \
   --docker-image docker:git \
   --docker-volumes '/var/run/docker.sock:/var/run/docker.sock' \
   --docker-volumes '/builds:/builds' \
   --docker-volumes '/etc/hosts:/etc/hosts'

The Runner config will be stored in /etc/gitlab-runner/config.toml and looks something like this:

concurrent = 2
check_interval = 0
user = "gitlab-runner"
shutdown_timeout = 0

[session_server]
  session_timeout = 1800

[[runners]]
  name = "blog-runner-01"
  url = "https://gitlab.com"
  id = 0
  token = "my-runner-token"
  token_obtained_at = 0001-01-01T00:00:00Z
  token_expires_at = 0001-01-01T00:00:00Z
  executor = "docker"
  [runners.docker]
    tls_verify = false
    image = "docker:git"
    privileged = false
    disable_entrypoint_overwrite = false
    oom_kill_disable = false
    disable_cache = false
    cache_dir = "/cache"
    volumes = ["/var/run/docker.sock:/var/run/docker.sock", "/builds:/builds", "/etc/hosts:/etc/hosts", "/cache:/cache"]
    pull_policy = ["if-not-present"]
    shm_size = 0

I’d like to highlight a few things:

  • concurrent - how many jobs can run simultaneously
  • executor - which executor we’re using, in this case Docker
  • volumes - which volumes are mounted in Docker; in my case /var/run/docker.sock, /builds, /etc/hosts, and /cache. As I mentioned above, be careful with the Docker socket — it’s a security risk. Since Docker runs as root, you’re essentially giving root access to third parties.

More about GitLab Runner configuration can be found in the documentation.

Scaling Runners

Previously, GitLab Runner supported the Docker Machine executor, which could automatically create (and dispose of) virtual machines in the cloud. GitLab Runner itself didn’t handle the overhead — Docker Machine took care of everything. But Docker Machine was archived at the end of September 2021 and is no longer supported. GitLab maintained a Docker Machine fork for some time. But starting with GitLab version 17.1.0 (June 2024), a replacement is available in the form of the Fleeting plugin for GitLab Runner, which handles scaling of runner machines in the cloud.

Fleeting is open-source, making it very easy to write your own plugin for your infrastructure. There are already several official and community plugins for various cloud providers. Currently supported are AWS, Azure, GCP, Kubernetes, Hetzner Cloud, and OpenStack. For VMware vSphere, GitLab is looking for contributors.

Another executor is Kubernetes, meaning you can deploy the Runner directly into a Kubernetes cluster and scale as needed. With the help of Cluster Autoscaler, you can also scale the cluster itself — and thus theoretically scale to infinity.

A list of all executors and their configuration can be found in the documentation.

GitHub Actions

The fundamental building blocks of GitHub Actions are:

  • workflow (pipeline) - describes when the workflow runs, its name; it can be independent or wait for another workflow
  • job - a single task within a workflow
  • step - a single task (step) within a job
  • action - a reusable step

A workflow is defined in a .github/workflows/*.yml file. A single repository can have multiple workflow files. But they must always be in the .github/workflows/ directory.

GitHub Actions Examples

Let’s look at a simple example, for a JavaScript application:

# .github/workflows/nodejs.yml

name: Node.js CI

on: [push]

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4
      - name: Use Node.js
        uses: actions/setup-node@v4
        with:
          node-version: "20"
      - run: npm ci
      - run: npm run build
      - run: npm run test

Again, this is a trivial pipeline, but you have to start somewhere.

Docker build and push to GitHub Container Registry:

# .github/workflows/docker-build.yml

name: Docker build

on:
  push:
    branches:
      - '*'

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
- name: checkout
        uses: actions/checkout@v4
        with:
          ref: ${{ github.head_ref }} # checkout the correct branch name
          fetch-tags: true

      - name: setup qemu
        uses: docker/setup-qemu-action@v3

      - name: setup docker
        uses: docker/setup-buildx-action@v3

      - name: login to ghcr.io
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: docker metadata
        uses: docker/metadata-action@v5
        id: meta
        with:
          images: |
            ghcr.io/${{ github.repository }}
          tags: |
            type=ref,event=branch,suffix=-${{ github.sha }}
            type=sha,format=long
            type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', 'main') }}

      - name: build and push
        uses: docker/build-push-action@v6
        with:
          context: .
          platforms: linux/amd64,linux/arm64
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}

Note: this is again a multi-platform Docker image build for linux/amd64 and linux/arm64.

If you want to use more sophisticated solutions than bash scripts or existing actions that may not suit your needs, you can use the “GitHub script” action, which lets you write JavaScript and work with the GitHub API via its SDK:

# .github/workflows/github-script.yml

name: GitHub Script

on:
  push:
    branches:
      - "*"

jobs:
  script:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Run a script
        uses: actions/github-script@v5
        with:
          script: |
            const { data: issues } = await github.issues.listForRepo({
              owner: context.repo.owner,
              repo: context.repo.repo,
            });
            core.setOutput('issues', JSON.stringify(issues));

The example above lists all issues in the repository in JSON format.

If you’re interested in GitHub Actions and want to learn more or improve your existing workflows, I’m also preparing a training for GitHub Actions.

Custom Actions

If you don’t want to “hack” JavaScript in your pipeline, you can create your own action that you can then use anywhere in your workflows. Actions are written in JavaScript/TypeScript and are packaged into a Docker image or just a Dockerfile (Linux only), or you can compose actions to create so-called composite actions.

More about custom actions can be found in the documentation.

GitHub Actions Runner

GitHub Actions Runner is an open-source project written in C# that can run on Linux, Windows, and macOS.

You can assign a Runner to a repository or an organization.

For a repository, the procedure is as follows:

  1. Go to your repository
  2. Open the Settings tab
  3. In the left menu, select Actions
  4. In the expanded Runners section
  5. Click the New self-hosted runner button
  6. Follow the instructions — it’s a few shell commands you run on the target machine

Such a Runner handles only one job at a time. The Runner can be scaled using GitHub webhooks — when you receive a workflow_job event with queued activity.

Or you can use the GitHub Actions Runner Controller, a Kubernetes operator that lets you run (and scale) Runners on a Kubernetes cluster.

More about GitHub Actions Runner configuration can be found in the documentation.

Scaling Runners

The official GitHub runner can only handle one job at a time. This means if you have several workflows that could run in parallel but only one runner, they will run sequentially. Because the GitHub Actions runner processes jobs from one workflow sequentially, and only after it completes moves on to the next workflow.

So to scale runners, we need to run multiple GitHub runner instances. Either on one machine (virtual or physical) or across multiple machines. On your own hardware, GitHub runners don’t scale well, but in the cloud it’s very easy — you just spin up and tear down machines as needed.

Alternatively, if you run runners in Kubernetes, scaling is easier up to a configured maximum, and actions-runner-controller manages the number of runners automatically. If it runs out of resources and you have Cluster Autoscaler in your cluster, you can automatically scale the cluster nodes and thus the runners as well.

Final Comparison

GitLab CIGitHub Actions
Open-source platformYesNo
Open-source runnerYesYes
Runner scalingYesYes
ExtensibilityYesYes
Multiple pipelinesNo1Yes
EnvironmentDocker, ShellOS
Runner parallelizationYes, configurableNo
Runner scalingYes2Yes3
Reusability, templatesYes, file inclusionYes, actions
  1. GitLab CI does allow multiple pipelines, but the configuration is somewhat cumbersome — these are so-called parent-child pipelines.
  2. GitLab Runner can be scaled using the Fleeting system or by deploying the Runner to a Kubernetes cluster and scaling there.
  3. GitHub Actions Runner can be scaled using webhooks to launch new runners, or via actions-runner-controller also on a Kubernetes cluster.

A Year with GitHub Actions

What follows is a personal experience with GitHub and GitHub Actions — your experience may differ.

At cybroslabs we decided to use GitHub because we plan to publish several projects as open-source in the future, and to avoid having to rewrite all pipelines from GitLab CI to GitHub Actions, we decided to start on GitHub and use Actions from the get-go.

When GitHub works as it should, everything is fine, but…

Over the course of a year, it happened several times that workflows took a very long time (sometimes tens of minutes) to start… With about four workflows per repository, that’s a pretty bad situation. We solved the runner issue by setting up our own self-hosted runner.

We also had problems several times with GitHub CodeSearch, both within a repository and across the organization. Which certainly didn’t help.

Because our applications run on Kubernetes, we use GitHub Container Registry (ghcr.io), but private packages can only be accessed with a GitHub Personal Access Token (PAT), so everything depends on a user — you can’t create a token at the organization or repository level. Which is really not great.

So internal projects will eventually be migrated to our own GitLab, with only open-source remaining on GitHub.

So my personal experience has been mediocre — I’d rate it 6/10. I personally prefer GitLab and its CI solution.

GitHub Actions training

Tired of running scripts over and over? Broken builds, failing tests, manual deployments? Start using automation on GitHub — GitHub Actions! Sign up for an open session or contact me about corporate training.

This website uses cookies for traffic analysis via Google Analytics. They help me improve the site based on anonymous statistics. More information in the cookie policy.