Tools: How to Set Up a CI/CD Pipeline with GitLab CI/CD: A Step-by-Step Guide - Guide

Tools: How to Set Up a CI/CD Pipeline with GitLab CI/CD: A Step-by-Step Guide - Guide

Why CI/CD Matters for Small Teams

What Is GitLab CI/CD?

Prerequisites

Step 1: Create Your .gitlab-ci.yml

Step 2: Set Up CI/CD Variables

Step 3: Understand the Pipeline Flow

Step 4: Add Security Scanning

Real-World Results

Common Pitfalls to Avoid

1. Not caching dependencies

2. Deploying on every branch

3. No manual approval for production

Next Steps If you're running a small tech team in Nepal — maybe a startup in Kathmandu or a digital agency in Lalitpur — you might think CI/CD is something "big companies" do. Here's the truth: CI/CD is even more valuable for small teams because every hour saved on manual work is an hour you can spend building features. In this guide, I'll walk you through setting up a complete CI/CD pipeline with GitLab CI/CD, from zero to production deployment. GitLab CI/CD is a built-in continuous integration and deployment tool. Every time you push code to your GitLab repository, it can automatically: All defined in a single .gitlab-ci.yml file. Create this file in your project root: In your GitLab project, go to Settings → CI/CD → Variables and add: Here's what happens on each push to main: For teams managing sensitive applications in Nepal (fintech, health tech, etc.), add a security scan: At Andmine Pvt Ltd, where I work as a System Administrator, implementing CI/CD pipelines reduced our deployment time from ~45 minutes per deployment to under 3 minutes. Manual errors dropped to near zero, and our team gained confidence in shipping code on Fridays. Use rules: to control when pipelines run. Don't deploy feature branches to production. The when: manual flag on your deploy stage prevents accidental production deployments. Once you have the basics working: Need help setting up CI/CD for your team? I work with organizations across Nepal to build reliable deployment pipelines. Get in touch. 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

Command

Copy

$ stages: - test - build - deploy variables: DOCKER_IMAGE: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA # Stage 1: Run tests test: stage: test image: node:20-alpine script: - -weight: 500;">npm ci - -weight: 500;">npm run test - -weight: 500;">npm run lint rules: - if: $CI_MERGE_REQUEST_IID - if: $CI_COMMIT_BRANCH == "main" # Stage 2: Build Docker image build: stage: build image: -weight: 500;">docker:24.0 services: - -weight: 500;">docker:24.0-dind script: - -weight: 500;">docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY - -weight: 500;">docker build -t $DOCKER_IMAGE . - -weight: 500;">docker push $DOCKER_IMAGE rules: - if: $CI_COMMIT_BRANCH == "main" # Stage 3: Deploy to production deploy: stage: deploy image: alpine:latest script: - -weight: 500;">apk add --no-cache openssh-client - mkdir -p ~/.ssh - echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519 - chmod 600 ~/.ssh/id_ed25519 - ssh -o StrictHostKeyChecking=no user@your-server.com " -weight: 500;">docker pull $DOCKER_IMAGE && -weight: 500;">docker -weight: 500;">stop app || true && -weight: 500;">docker rm app || true && -weight: 500;">docker run -d --name app -p 3000:3000 $DOCKER_IMAGE " rules: - if: $CI_COMMIT_BRANCH == "main" when: manual stages: - test - build - deploy variables: DOCKER_IMAGE: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA # Stage 1: Run tests test: stage: test image: node:20-alpine script: - -weight: 500;">npm ci - -weight: 500;">npm run test - -weight: 500;">npm run lint rules: - if: $CI_MERGE_REQUEST_IID - if: $CI_COMMIT_BRANCH == "main" # Stage 2: Build Docker image build: stage: build image: -weight: 500;">docker:24.0 services: - -weight: 500;">docker:24.0-dind script: - -weight: 500;">docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY - -weight: 500;">docker build -t $DOCKER_IMAGE . - -weight: 500;">docker push $DOCKER_IMAGE rules: - if: $CI_COMMIT_BRANCH == "main" # Stage 3: Deploy to production deploy: stage: deploy image: alpine:latest script: - -weight: 500;">apk add --no-cache openssh-client - mkdir -p ~/.ssh - echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519 - chmod 600 ~/.ssh/id_ed25519 - ssh -o StrictHostKeyChecking=no user@your-server.com " -weight: 500;">docker pull $DOCKER_IMAGE && -weight: 500;">docker -weight: 500;">stop app || true && -weight: 500;">docker rm app || true && -weight: 500;">docker run -d --name app -p 3000:3000 $DOCKER_IMAGE " rules: - if: $CI_COMMIT_BRANCH == "main" when: manual stages: - test - build - deploy variables: DOCKER_IMAGE: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA # Stage 1: Run tests test: stage: test image: node:20-alpine script: - -weight: 500;">npm ci - -weight: 500;">npm run test - -weight: 500;">npm run lint rules: - if: $CI_MERGE_REQUEST_IID - if: $CI_COMMIT_BRANCH == "main" # Stage 2: Build Docker image build: stage: build image: -weight: 500;">docker:24.0 services: - -weight: 500;">docker:24.0-dind script: - -weight: 500;">docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY - -weight: 500;">docker build -t $DOCKER_IMAGE . - -weight: 500;">docker push $DOCKER_IMAGE rules: - if: $CI_COMMIT_BRANCH == "main" # Stage 3: Deploy to production deploy: stage: deploy image: alpine:latest script: - -weight: 500;">apk add --no-cache openssh-client - mkdir -p ~/.ssh - echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519 - chmod 600 ~/.ssh/id_ed25519 - ssh -o StrictHostKeyChecking=no user@your-server.com " -weight: 500;">docker pull $DOCKER_IMAGE && -weight: 500;">docker -weight: 500;">stop app || true && -weight: 500;">docker rm app || true && -weight: 500;">docker run -d --name app -p 3000:3000 $DOCKER_IMAGE " rules: - if: $CI_COMMIT_BRANCH == "main" when: manual graph LR A[Push to main] --> B[test] B --> C[build] C --> D{deploy} D -->|manual trigger| E[Production] graph LR A[Push to main] --> B[test] B --> C[build] C --> D{deploy} D -->|manual trigger| E[Production] graph LR A[Push to main] --> B[test] B --> C[build] C --> D{deploy} D -->|manual trigger| E[Production] security-scan: stage: test image: aquasec/trivy:latest script: - trivy image --severity HIGH,CRITICAL $DOCKER_IMAGE allow_failure: true rules: - if: $CI_COMMIT_BRANCH == "main" security-scan: stage: test image: aquasec/trivy:latest script: - trivy image --severity HIGH,CRITICAL $DOCKER_IMAGE allow_failure: true rules: - if: $CI_COMMIT_BRANCH == "main" security-scan: stage: test image: aquasec/trivy:latest script: - trivy image --severity HIGH,CRITICAL $DOCKER_IMAGE allow_failure: true rules: - if: $CI_COMMIT_BRANCH == "main" test: cache: key: ${CI_COMMIT_REF_SLUG} paths: - node_modules/ test: cache: key: ${CI_COMMIT_REF_SLUG} paths: - node_modules/ test: cache: key: ${CI_COMMIT_REF_SLUG} paths: - node_modules/ - Run your tests - Build your application - Deploy to staging or production - Run security scans - A GitLab account (free tier works fine) - A project with at least a basic application - Understanding of basic shell commands - A server or cloud instance for deployment (AWS, Azure, or a VPS) - Test stage — Installs dependencies, runs tests and linting - Build stage — Creates a Docker image and pushes to GitLab's registry - Deploy stage — SSH's into your server and deploys the new image (manual trigger for safety) - Add monitoring with Prometheus and Grafana - Set up automatic rollbacks on health check failures - Implement canary deployments for zero-risk releases - Use GitLab Environments to track deployment history