Tools: Update: GitHub Actions vs GitLab CI: a practical comparison

Tools: Update: GitHub Actions vs GitLab CI: a practical comparison

GitHub Actions vs GitLab CI: a practical comparison

syntax and configuration

GitHub Actions

GitLab CI

performance and speed

build times

parallelization

ecosystem and marketplace

GitHub Actions marketplace

GitLab's approach

docker integration

GitLab CI wins here

GitHub Actions

secrets management

GitHub

GitLab

self-hosted runners

GitHub

GitLab

debugging experience

when to pick which

my setup

common pitfalls

migration tips

GitHub to GitLab

GitLab to GitHub Two years, 50 microservices, two CI platforms running side by side. Some repos on GitHub, some on GitLab, same team writing the YAML for both. Here is what stuck after the marketing slides wore off. The YAML is readable, the marketplace has an action for almost everything, and matrix builds are a single block. The nesting gets verbose once you have reusable workflows, and environment variable precedence is its own small religion. Flatter than GitHub's nesting, Docker is a first-class citizen, and the stages concept maps cleanly to how you think about a pipeline. There is no marketplace, so reusable components come from include: files and Docker images you assemble yourself. A typical Node.js app on our setup builds in 3 to 5 minutes on GitHub Actions and 4 to 6 minutes on GitLab CI. Close enough that I never picked a platform on speed alone. Both handle parallel jobs well. GitHub Actions has cleaner syntax for matrix builds: GitLab requires more manual setup for the same result. Over 20,000 actions, and the caching one is the example I keep coming back to: One block, content-addressed cache keyed off the lockfile. The first time you delete the manual cache logic you wrote for GitLab and replace it with this, you feel it. GitLab does not have a marketplace. You write scripts or use Docker images: More control, but more work. GitLab CI was built with Docker in mind: It just works. No weird permissions issues. Needs more setup for Docker. Works fine, but requires more marketplace actions. Simple. Secrets are org/repo scoped. Works well. More flexible with group-level variables and environments. Better for complex setups. GitHub Actions gives private repos 2,000 minutes/month on the free tier, public repos are unlimited, and overage is $0.008/minute. GitLab SaaS gives 400 minutes/month free and charges $10 per 1,000 additional minutes, but self-hosted runners are unlimited. If you can run your own runners, GitLab gets cheaper fast at scale. If you can't, GitHub's free tier outlasts it. Setup is straightforward. Runners are repo or org-scoped. More flexible. Can be project, group, or instance-wide. Better for large organizations. GitHub Actions has clear, searchable logs, lets you re-run individual jobs, and exposes a debug mode behind two secrets. You can SSH into a runner via a third-party action, but it is not a native feature. GitLab is the one I reach for when a pipeline is genuinely stuck. The log viewer is good, individual job retries are good, but the real difference is interactive debugging. SSH into the runner mid-job, or open a web terminal from the failed job in your browser, and poke at the filesystem while the build is still alive. The first time you do this on a Docker-in-Docker failure that only repros on CI, you stop missing it everywhere else. GitHub Actions wins when you are already on GitHub, want the marketplace, and your pipelines are small to medium. GitLab CI wins when your Docker workflows are non-trivial, your runner fleet is large, your deployment strategies are gnarly, or you need to debug pipelines without a redeploy loop. I use both. GitHub Actions for open-source and frontend, GitLab CI for infrastructure code and the deployments that involve five stages and a manual approval. GitHub Actions has a 6-hour hosted-runner job timeout, a 90-day artifact retention default (configurable up to 400 days for public repos, 90 for private), and tight concurrent-job limits on the free tier. Plan around them or pay. GitLab's shared runners get sluggish at peak, Docker builds need docker:dind as a service container, and CI/CD variable precedence has at least six rules you will need to read twice. The one that bites me most: project-level variables silently override group-level ones with the same name. Most scripts translate directly. The win is collapsing a few of them into marketplace actions you no longer have to maintain. Starting fresh, pick whichever platform already hosts your code. The integration tax of running CI on the other vendor outweighs every syntax preference in this post. Whichever one you pick, the only investment that pays back is making the pipeline fast. A slow CI is worse than no CI; it just costs more to ignore. Templates let you quickly answer FAQs or store snippets for re-use. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse

Code Block

Copy

name: CI Pipeline on: push: branches: [main] pull_request: jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: '20' - run: npm ci - run: npm test name: CI Pipeline on: push: branches: [main] pull_request: jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: '20' - run: npm ci - run: npm test name: CI Pipeline on: push: branches: [main] pull_request: jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: '20' - run: npm ci - run: npm test stages: - test - build test: stage: test image: node:20 script: - npm ci - npm test only: - main - merge_requests stages: - test - build test: stage: test image: node:20 script: - npm ci - npm test only: - main - merge_requests stages: - test - build test: stage: test image: node:20 script: - npm ci - npm test only: - main - merge_requests strategy: matrix: node-version: [18, 20, 22] os: [ubuntu-latest, windows-latest] strategy: matrix: node-version: [18, 20, 22] os: [ubuntu-latest, windows-latest] strategy: matrix: node-version: [18, 20, 22] os: [ubuntu-latest, windows-latest] - uses: actions/cache@v4 with: path: ~/.npm key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} - uses: actions/cache@v4 with: path: ~/.npm key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} - uses: actions/cache@v4 with: path: ~/.npm key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} test: image: node:20 cache: key: ${CI_COMMIT_REF_SLUG} paths: - node_modules/ script: - npm ci - npm test test: image: node:20 cache: key: ${CI_COMMIT_REF_SLUG} paths: - node_modules/ script: - npm ci - npm test test: image: node:20 cache: key: ${CI_COMMIT_REF_SLUG} paths: - node_modules/ script: - npm ci - npm test build: image: docker:latest services: - docker:dind script: - docker build -t myapp . - docker push myapp build: image: docker:latest services: - docker:dind script: - docker build -t myapp . - docker push myapp build: image: docker:latest services: - docker:dind script: - docker build -t myapp . - docker push myapp - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Build and push uses: docker/build-push-action@v5 with: context: . push: true tags: myapp:latest - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Build and push uses: docker/build-push-action@v5 with: context: . push: true tags: myapp:latest - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Build and push uses: docker/build-push-action@v5 with: context: . push: true tags: myapp:latest env: API_KEY: ${{ secrets.API_KEY }} env: API_KEY: ${{ secrets.API_KEY }} env: API_KEY: ${{ secrets.API_KEY }} variables: API_KEY: $CI_DEPLOY_TOKEN variables: API_KEY: $CI_DEPLOY_TOKEN variables: API_KEY: $CI_DEPLOY_TOKEN ./config.sh --url https://github.com/org/repo --token TOKEN ./run.sh ./config.sh --url https://github.com/org/repo --token TOKEN ./run.sh ./config.sh --url https://github.com/org/repo --token TOKEN ./run.sh gitlab-runner register gitlab-runner run gitlab-runner register gitlab-runner run gitlab-runner register gitlab-runner run # GitHub - uses: actions/checkout@v4 # GitLab equivalent git clone $CI_REPOSITORY_URL cd $CI_PROJECT_NAME # GitHub - uses: actions/checkout@v4 # GitLab equivalent git clone $CI_REPOSITORY_URL cd $CI_PROJECT_NAME # GitHub - uses: actions/checkout@v4 # GitLab equivalent git clone $CI_REPOSITORY_URL cd $CI_PROJECT_NAME