DevOps

Continuous Deployment with GitLab CI/CD from scratch

Introduction

Automating your software delivery pipeline is essential for modern development teams. Manual deployments are error-prone, time-consuming, and don’t scale as your team grows. GitLab CI/CD provides a powerful, built-in solution that integrates seamlessly with your Git workflow, allowing you to automatically test, build, and deploy your applications with every code change. Unlike external CI/CD tools that require separate configuration and maintenance, GitLab’s pipeline system lives right alongside your code, making it easy to version, review, and maintain. In this comprehensive guide, you’ll learn how to set up a complete CI/CD pipeline from scratch, including testing, building, deploying to multiple environments, and following production-ready best practices.

What Is CI/CD?

CI/CD stands for Continuous Integration and Continuous Deployment (or Delivery). These practices form the backbone of modern DevOps workflows.

Continuous Integration (CI) automatically tests and builds your code whenever changes are pushed. Every commit triggers a pipeline that runs your test suite, ensuring new code doesn’t break existing functionality. CI catches bugs early when they’re cheapest to fix.

Continuous Deployment (CD) automatically releases changes to staging or production once they pass all tests. With CD, deployments become routine events that happen multiple times per day rather than stressful monthly releases.

GitLab combines both into a single, built-in system that’s easy to configure yet powerful enough for enterprise teams managing hundreds of microservices.

Step 1: Create a GitLab Repository

Start by creating a new project in GitLab. You can create a blank project, import from GitHub, or push an existing local repository.

# Initialize a new repository locally
git init
git add .
git commit -m "Initial commit"

# Add GitLab as remote and push
git remote add origin https://gitlab.com/username/project-name.git
git branch -M main
git push -u origin main

Once your project is online, GitLab automatically enables CI/CD features. You’ll see the CI/CD menu in your project’s sidebar, ready to run pipelines as soon as you add configuration.

Step 2: Add a .gitlab-ci.yml File

The .gitlab-ci.yml file defines your entire pipeline. Create it in the root of your repository. Here’s a comprehensive example for a Node.js application:

# Define pipeline stages - they run in order
stages:
  - install
  - test
  - build
  - deploy

# Global settings
variables:
  NODE_VERSION: "20"

# Cache node_modules between jobs
cache:
  key: ${CI_COMMIT_REF_SLUG}
  paths:
    - node_modules/
    - .npm/

# Install dependencies
install_dependencies:
  stage: install
  image: node:${NODE_VERSION}
  script:
    - npm ci --cache .npm --prefer-offline
  artifacts:
    paths:
      - node_modules/
    expire_in: 1 hour

# Run linting
lint:
  stage: test
  image: node:${NODE_VERSION}
  script:
    - npm run lint
  dependencies:
    - install_dependencies

# Run unit tests
unit_tests:
  stage: test
  image: node:${NODE_VERSION}
  script:
    - npm run test:unit -- --coverage
  coverage: '/All files[^|]*\|[^|]*\s+([\d\.]+)/'
  artifacts:
    reports:
      junit: junit.xml
      coverage_report:
        coverage_format: cobertura
        path: coverage/cobertura-coverage.xml
  dependencies:
    - install_dependencies

# Run integration tests
integration_tests:
  stage: test
  image: node:${NODE_VERSION}
  services:
    - postgres:15
    - redis:7
  variables:
    POSTGRES_DB: test_db
    POSTGRES_USER: test_user
    POSTGRES_PASSWORD: test_pass
    DATABASE_URL: "postgresql://test_user:test_pass@postgres:5432/test_db"
    REDIS_URL: "redis://redis:6379"
  script:
    - npm run test:integration
  dependencies:
    - install_dependencies

# Build production assets
build:
  stage: build
  image: node:${NODE_VERSION}
  script:
    - npm run build
  artifacts:
    paths:
      - dist/
    expire_in: 1 week
  dependencies:
    - install_dependencies
  only:
    - main
    - develop

# Deploy to staging
deploy_staging:
  stage: deploy
  image: alpine:latest
  before_script:
    - apk add --no-cache openssh-client rsync
    - eval $(ssh-agent -s)
    - echo "$SSH_PRIVATE_KEY" | ssh-add -
    - mkdir -p ~/.ssh
    - echo "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
  script:
    - rsync -avz --delete dist/ $STAGING_USER@$STAGING_HOST:$STAGING_PATH
    - ssh $STAGING_USER@$STAGING_HOST "cd $STAGING_PATH && pm2 restart ecosystem.config.js --env staging"
  environment:
    name: staging
    url: https://staging.example.com
  dependencies:
    - build
  only:
    - develop

# Deploy to production
deploy_production:
  stage: deploy
  image: alpine:latest
  before_script:
    - apk add --no-cache openssh-client rsync
    - eval $(ssh-agent -s)
    - echo "$SSH_PRIVATE_KEY" | ssh-add -
    - mkdir -p ~/.ssh
    - echo "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
  script:
    - rsync -avz --delete dist/ $PROD_USER@$PROD_HOST:$PROD_PATH
    - ssh $PROD_USER@$PROD_HOST "cd $PROD_PATH && pm2 restart ecosystem.config.js --env production"
  environment:
    name: production
    url: https://example.com
  dependencies:
    - build
  only:
    - main
  when: manual

Each stage runs sequentially, while jobs within a stage run in parallel. If any job fails, GitLab stops the pipeline automatically, preventing broken code from reaching production.

Step 3: Add Environment Variables

Sensitive data like API keys, SSH credentials, and deployment targets should never be committed to your repository. GitLab provides secure variable storage.

Navigate to your GitLab project → Settings → CI/CD → Variables and add your credentials:

# Essential variables for deployment
SSH_PRIVATE_KEY        # Your deployment SSH private key (masked, protected)
SSH_KNOWN_HOSTS        # SSH known_hosts content for your servers

# Staging environment
STAGING_HOST           # staging.example.com
STAGING_USER           # deploy
STAGING_PATH           # /var/www/staging

# Production environment
PROD_HOST              # example.com
PROD_USER              # deploy
PROD_PATH              # /var/www/production

# Application secrets
DATABASE_URL           # PostgreSQL connection string
REDIS_URL              # Redis connection string
API_SECRET_KEY         # Application secret key

Enable Protected for production variables to ensure they’re only available on protected branches like main. Enable Masked to hide values in job logs.

Step 4: Configure GitLab Runners

GitLab Runners execute your pipeline jobs. You can use GitLab’s shared runners or set up your own for better performance and security.

Using Shared Runners

GitLab.com provides free shared runners that work out of the box. They’re suitable for most projects and require no configuration.

Setting Up Self-Hosted Runners

For faster builds or private network access, install your own runner:

# Install GitLab Runner on Ubuntu
curl -L --output /usr/local/bin/gitlab-runner https://gitlab-runner-downloads.s3.amazonaws.com/latest/binaries/gitlab-runner-linux-amd64
chmod +x /usr/local/bin/gitlab-runner
gitlab-runner install --user=gitlab-runner --working-directory=/home/gitlab-runner
gitlab-runner start

# Register the runner
gitlab-runner register \
  --url https://gitlab.com/ \
  --registration-token YOUR_REGISTRATION_TOKEN \
  --executor docker \
  --docker-image alpine:latest \
  --description "My Docker Runner" \
  --tag-list "docker,linux"

Self-hosted runners can access private networks, use cached Docker images, and provide consistent build environments.

Step 5: Implement Docker-Based Deployments

For more robust deployments, use Docker containers. Here’s a pipeline that builds and deploys Docker images:

stages:
  - test
  - build
  - deploy

variables:
  DOCKER_IMAGE: $CI_REGISTRY_IMAGE
  DOCKER_TAG: $CI_COMMIT_SHA

# Build Docker image
build_image:
  stage: build
  image: docker:24
  services:
    - docker:24-dind
  variables:
    DOCKER_TLS_CERTDIR: "/certs"
  before_script:
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
  script:
    - docker build -t $DOCKER_IMAGE:$DOCKER_TAG -t $DOCKER_IMAGE:latest .
    - docker push $DOCKER_IMAGE:$DOCKER_TAG
    - docker push $DOCKER_IMAGE:latest
  only:
    - main
    - develop

# Deploy with Docker Compose
deploy_docker:
  stage: deploy
  image: alpine:latest
  before_script:
    - apk add --no-cache openssh-client
    - eval $(ssh-agent -s)
    - echo "$SSH_PRIVATE_KEY" | ssh-add -
    - mkdir -p ~/.ssh
    - echo "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
  script:
    - |
      ssh $DEPLOY_USER@$DEPLOY_HOST << EOF
        cd $DEPLOY_PATH
        docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
        docker pull $DOCKER_IMAGE:$DOCKER_TAG
        docker-compose down
        export IMAGE_TAG=$DOCKER_TAG
        docker-compose up -d
        docker system prune -f
      EOF
  environment:
    name: production
    url: https://example.com
  only:
    - main
  when: manual

Step 6: Monitor and Debug Pipelines

GitLab provides comprehensive pipeline monitoring through the CI/CD → Pipelines interface. Each pipeline shows job status, duration, and detailed logs.

Pipeline Visualization

The pipeline graph shows job dependencies and parallel execution. Failed jobs are highlighted in red with one-click access to logs.

Environment Tracking

The Deployments → Environments page shows which commits are deployed to each environment, with one-click rollback capability.

Debugging Failed Jobs

# Add debug output to troubleshoot failures
debug_job:
  stage: test
  script:
    - echo "Current directory: $(pwd)"
    - echo "Files: $(ls -la)"
    - echo "Environment variables:"
    - env | grep -v PASSWORD | grep -v SECRET | sort
    - npm run test -- --verbose

Best Practices for Production Pipelines

Following these practices ensures reliable, maintainable CI/CD pipelines:

Use Branch-Specific Pipelines

# Different behavior per branch
deploy:
  script: ./deploy.sh
  rules:
    - if: $CI_COMMIT_BRANCH == "main"
      variables:
        ENVIRONMENT: production
    - if: $CI_COMMIT_BRANCH == "develop"
      variables:
        ENVIRONMENT: staging
    - if: $CI_MERGE_REQUEST_ID
      variables:
        ENVIRONMENT: review

Implement Pipeline Caching

# Cache dependencies between pipelines
cache:
  key:
    files:
      - package-lock.json
  paths:
    - node_modules/
  policy: pull-push

Use Templates for Reusability

# Define reusable templates
.deploy_template: &deploy_template
  image: alpine:latest
  before_script:
    - apk add --no-cache openssh-client
    - eval $(ssh-agent -s)
    - echo "$SSH_PRIVATE_KEY" | ssh-add -

deploy_staging:
  <<: *deploy_template
  script: ./deploy.sh staging

deploy_production:
  <<: *deploy_template
  script: ./deploy.sh production

Add Manual Approval Gates

# Require manual approval for production
deploy_production:
  stage: deploy
  script: ./deploy.sh production
  when: manual
  allow_failure: false
  only:
    - main

Common Mistakes to Avoid

Not using artifacts correctly: Pass build outputs between stages using artifacts, not cache. Artifacts are guaranteed to be available; cache is best-effort.

Ignoring pipeline duration: Long pipelines slow down development. Use caching, parallel jobs, and consider splitting large test suites.

Storing secrets in code: Never commit API keys, passwords, or certificates. Always use GitLab's protected variables.

No rollback strategy: Always have a way to quickly revert deployments. Use environment tracking and keep previous Docker images or build artifacts.

Skipping staging: Always test deployments in a staging environment before production, especially for infrastructure changes.

Final Thoughts

GitLab CI/CD makes continuous deployment simple, powerful, and scalable. With just a .gitlab-ci.yml file, you can automate everything — from linting and testing to building Docker images and deploying to multiple environments — in minutes. The integrated approach means your pipeline configuration is versioned alongside your code, reviewed in merge requests, and always in sync with your application. As your team grows, GitLab scales with you, supporting complex workflows with approval gates, environment-specific deployments, and comprehensive monitoring. If you're exploring other CI/CD setups, check out CI/CD with GitHub, Firebase, and Docker. To dive deeper into automation best practices and advanced features, see the GitLab CI/CD documentation.

Leave a Comment