ServerlessBase Blog
  • GitLab CI/CD: Features and Configuration

    A comprehensive guide to GitLab CI/CD features and configuration for modern DevOps workflows

    GitLab CI/CD: Features and Configuration

    You've probably used GitLab for version control, but its built-in CI/CD pipeline is a powerful tool that can replace multiple external services. I've seen teams save thousands of dollars by eliminating Jenkins, CircleCI, and separate deployment tools. GitLab CI/CD integrates directly with your repository, making it easier to maintain and debug pipelines.

    This guide covers the core features and practical configuration patterns you'll use daily.

    Understanding GitLab CI/CD Architecture

    GitLab CI/CD runs in a separate runner process that executes jobs defined in your .gitlab-ci.yml file. The runner can be installed on your own infrastructure or managed by GitLab. When you push code, GitLab triggers the pipeline, which executes each job sequentially or in parallel depending on your configuration.

    The pipeline consists of stages, and jobs within the same stage run in parallel. This parallelization is one of GitLab's most useful features for speeding up builds.

    Core Features Overview

    1. Pipeline Configuration

    The .gitlab-ci.yml file defines your entire CI/CD process. Here's a minimal example:

    stages:
      - build
      - test
      - deploy
     
    build:
      stage: build
      image: node:18
      script:
        - npm install
        - npm run build
      artifacts:
        paths:
          - dist/
     
    test:
      stage: test
      image: node:18
      script:
        - npm test
      dependencies:
        - build
     
    deploy:
      stage: deploy
      image: alpine:latest
      script:
        - apk add --no-cache openssh-client
        - eval $(ssh-agent -s)
        - echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add -
        - mkdir -p ~/.ssh
        - chmod 700 ~/.ssh
        - ssh -o StrictHostKeyChecking=no user@server "cd /var/www && git pull origin main"
      only:
        - main

    This configuration defines three stages: build, test, and deploy. The build job creates an artifact (the dist/ folder) that the test job can use. The deploy job runs only on the main branch.

    2. Caching for Faster Builds

    Reinstalling dependencies on every build is slow. GitLab CI/CD supports caching to store dependencies between jobs:

    build:
      stage: build
      image: node:18
      script:
        - npm install
        - npm run build
      cache:
        key: ${CI_COMMIT_REF_SLUG}
        paths:
          - node_modules/
          - .npm/

    The cache key uses the branch name, so each branch gets its own cache. This significantly reduces build times, especially for projects with many dependencies.

    3. Docker Integration

    GitLab has built-in Docker support. You can use Docker-in-Docker (DinD) to build and push images:

    build:
      stage: build
      image: docker:24
      services:
        - docker:24-dind
      variables:
        DOCKER_DRIVER: overlay2
      script:
        - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
        - docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA .
        - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA

    The services section runs Docker-in-Docker, which allows the build job to use Docker commands. This is essential for building container images.

    4. Environment Management

    GitLab allows you to define environments for different deployment targets:

    stages:
      - deploy
     
    deploy_staging:
      stage: deploy
      environment:
        name: staging
        url: https://staging.example.com
      script:
        - kubectl apply -f k8s/staging/
      only:
        - develop
     
    deploy_production:
      stage: deploy
      environment:
        name: production
        url: https://example.com
      script:
        - kubectl apply -f k8s/production/
      when: manual
      only:
        - main

    The environment block defines the deployment target and its URL. The when: manual keyword requires manual approval before deploying to production, which is a safety feature.

    5. Protected Branches and Variables

    You can protect branches to prevent unauthorized deployments:

    deploy_production:
      stage: deploy
      environment:
        name: production
      script:
        - kubectl apply -f k8s/production/
      only:
        - main
      variables:
        DEPLOY_TOKEN: $PRODUCTION_DEPLOY_TOKEN
      rules:
        - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
          when: never
        - if: '$CI_COMMIT_BRANCH == "main"'

    The rules block prevents merge requests from deploying to production. Protected branches also require approval from maintainers.

    Advanced Features

    1. Matrix Builds

    You can test against multiple configurations simultaneously:

    test:
      stage: test
      image: node:18
      script:
        - npm test
      variables:
        NODE_ENV: development
      parallel:
        matrix:
          - NODE_VERSION: [16, 18, 20]
            OS: [linux, windows]

    This configuration runs 6 jobs (3 Node versions × 2 OS platforms) in parallel, testing against different environments.

    2. Scheduled Pipelines

    Automate regular tasks with scheduled pipelines:

    schedule:
      stage: test
      image: alpine:latest
      script:
        - echo "Running scheduled backup"
        - ./scripts/backup.sh
      only:
        - schedules
      variables:
        BACKUP_TYPE: daily

    The only: - schedules keyword ensures this job runs only on scheduled pipelines. You can set schedules in the GitLab UI under CI/CD → Schedules.

    3. Dependency Management

    Control which jobs can access artifacts from other jobs:

    build:
      stage: build
      script:
        - npm install
        - npm run build
      artifacts:
        paths:
          - dist/
        expire_in: 1 week
     
    test:
      stage: test
      script:
        - npm test
      dependencies:
        - build
      artifacts:
        reports:
          junit: test-results.xml

    The dependencies block explicitly lists which jobs can access the artifacts. This is useful when you have many jobs and want to control artifact propagation.

    4. Docker Compose Support

    GitLab has built-in Docker Compose support:

    services:
      postgres:
        image: postgres:15
        environment:
          POSTGRES_PASSWORD: password
          POSTGRES_DB: myapp
     
    test:
      stage: test
      image: node:18
      script:
        - npm install
        - npm test
        - docker compose up -d
        - npm run e2e

    The services section defines Docker Compose services that run alongside your job. This is perfect for testing against real databases or other services.

    Best Practices

    1. Use .gitlab-ci.yml in Root Directory

    Keep your pipeline configuration in the repository root. This makes it easy to find and maintain. If you have multiple projects, consider using a template repository.

    2. Separate Stages for Different Concerns

    Group jobs by their purpose:

    stages:
      - validate
      - build
      - test
      - package
      - deploy

    This separation makes it clear what each stage does and helps with debugging pipeline failures.

    3. Use .gitlab-ci.yml Templates

    GitLab provides templates for common configurations:

    include:
      - template: Security/SAST.gitlab-ci.yml
      - template: Security/Dependency-Scanning.gitlab-ci.yml
      - template: Security/License-Scanning.gitlab-ci.yml

    These templates add security scanning to your pipeline without writing custom jobs.

    4. Monitor Pipeline Performance

    Use GitLab's built-in metrics to identify slow jobs:

    build:
      stage: build
      image: node:18
      script:
        - npm install
        - npm run build
      timeout: 30 minutes

    The timeout keyword ensures jobs don't run indefinitely. GitLab shows job duration in the pipeline UI, helping you identify bottlenecks.

    Common Patterns

    1. Multi-Environment Deployment

    deploy_staging:
      stage: deploy
      environment:
        name: staging
        url: https://staging.example.com
      script:
        - kubectl config use-context staging
        - kubectl apply -f k8s/
      only:
        - develop
     
    deploy_production:
      stage: deploy
      environment:
        name: production
        url: https://example.com
      script:
        - kubectl config use-context production
        - kubectl apply -f k8s/
      when: manual
      only:
        - main

    This pattern uses Kubernetes contexts to manage different environments. The when: manual keyword requires approval for production deployments.

    2. Docker Registry Integration

    build_and_push:
      stage: build
      image: docker:24
      services:
        - docker:24-dind
      script:
        - docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA .
        - docker tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA $CI_REGISTRY_IMAGE:latest
        - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
        - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
        - docker push $CI_REGISTRY_IMAGE:latest
      only:
        - main

    This configuration builds and tags the image, then pushes both the commit SHA and latest tags to the registry.

    3. Database Migrations

    migrate:
      stage: deploy
      image: node:18
      script:
        - npm install
        - npx prisma migrate deploy
      environment:
        name: production
      only:
        - main

    This job runs database migrations after deployment. Using npx prisma migrate deploy ensures migrations run in the correct order.

    Troubleshooting

    1. Job Fails with "Permission Denied"

    Check your SSH keys and permissions:

    deploy:
      script:
        - eval $(ssh-agent -s)
        - echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add -
        - chmod 700 ~/.ssh
        - ssh -o StrictHostKeyChecking=no user@server "ls -la"

    The StrictHostKeyChecking=no option is useful during development but should be removed in production.

    2. Cache Not Working

    Verify your cache configuration:

    build:
      cache:
        key: ${CI_COMMIT_REF_SLUG}
        paths:
          - node_modules/
          - .npm/
        policy: pull-push

    The policy: pull-push keyword allows both reading and writing the cache. By default, it only reads.

    3. Docker Service Not Available

    Ensure you're using Docker-in-Docker:

    services:
      - docker:24-dind
     
    variables:
      DOCKER_DRIVER: overlay2
      DOCKER_TLS_CERTDIR: ""

    The DOCKER_TLS_CERTDIR: "" variable is required for DinD to work correctly.

    Conclusion

    GitLab CI/CD provides a comprehensive set of features for automating your development workflow. From caching and Docker integration to environment management and security scanning, it covers most CI/CD needs out of the box.

    The key to success is keeping your pipeline configuration simple and focused. Start with a basic pipeline, then add features as needed. Use GitLab's built-in templates for security scanning and other common tasks.

    Platforms like ServerlessBase can further simplify deployment by handling the infrastructure and reverse proxy configuration automatically, allowing you to focus on writing the pipeline configuration itself.

    Next Steps

    1. Review your current CI/CD pipeline and identify opportunities for improvement
    2. Implement caching to reduce build times
    3. Add security scanning templates to your pipeline
    4. Set up environment-specific deployments with manual approval for production
    5. Monitor pipeline performance and optimize slow jobs

    Leave comment