Build a Practical CI/CD Pipeline with GitLab CI (Tests → Build → Deploy to a VPS)

Build a Practical CI/CD Pipeline with GitLab CI (Tests → Build → Deploy to a VPS)

CI/CD sounds fancy, but the goal is simple: every push should automatically run checks, produce a deployable build, and (optionally) ship it to a server in a repeatable way. In this hands-on guide, you’ll set up a CI/CD pipeline using GitLab CI for a typical web app (Node-based frontend or API). You’ll run tests, build assets, and deploy to a Linux VPS over SSH.

This is aimed at junior/mid developers who want something they can actually copy, run, and extend.

What you’ll build

  • Stage 1: Lint + tests — fail fast on broken code.
  • Stage 2: Build — produce compiled assets (or a production bundle).
  • Stage 3: Deploy — upload build artifacts to a VPS and restart a service.

The key ideas: stages, cache (speed), artifacts (handoff between jobs), and protected variables (secrets).

Project assumptions

This example uses a Node app with common scripts:

  • npm run lint
  • npm test (or npm run test)
  • npm run build producing a dist/ folder

If you’re using a framework like Vite/React/Next (static export), Angular, or a Node API, the pipeline is nearly identical—the “build output folder” and deploy steps change.

Step 1: Add a GitLab runner-friendly config

Create a file named .gitlab-ci.yml at the root of your repo:

stages: - test - build - deploy # Use an official Node image for jobs image: node:20 # Speed up installs by caching dependencies cache: key: ${CI_COMMIT_REF_SLUG} paths: - node_modules/ - .npm/ variables: # Make npm cache local to project so GitLab can cache it NPM_CONFIG_CACHE: "${CI_PROJECT_DIR}/.npm" NODE_ENV: "production" # Run for merge requests and main branch pushes workflow: rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" - if: $CI_COMMIT_BRANCH == "main" test: stage: test script: - npm ci - npm run lint - npm test artifacts: when: always reports: junit: junit.xml rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" - if: $CI_COMMIT_BRANCH == "main" 

Notes:

  • npm ci is preferred in CI: it uses your lockfile and is reproducible.
  • cache makes installs much faster on repeat pipelines.
  • workflow.rules prevents running pipelines on every branch unless you want that.

If you don’t have JUnit output, you can remove the reports section. If your test runner supports it (Jest does), generating junit.xml helps GitLab show test results in the UI.

Step 2: Add a build job that exports artifacts

Now add the build stage. The trick is: your build output must be saved as artifacts so the deploy job can use it.

build: stage: build script: - npm ci - npm run build artifacts: expire_in: 1 week paths: - dist/ rules: - if: $CI_COMMIT_BRANCH == "main" 

Why run npm ci again? GitLab jobs run in fresh containers. You can optimize by using needs or a custom image, but this simple version is reliable and easy to understand.

If your build output folder isn’t dist/, change it (e.g., build/, .next/, out/).

Step 3: Set up SSH deploy secrets in GitLab

You’ll deploy to a VPS using SSH. In GitLab:

  • Go to Settings → CI/CD → Variables
  • Add these variables:
  • DEPLOY_HOST (e.g. 203.0.113.10)
  • DEPLOY_USER (e.g. deploy)
  • DEPLOY_PATH (e.g. /var/www/myapp)
  • SSH_PRIVATE_KEY (your deploy key, private part)

Mark sensitive ones as masked, and ideally protected so they only run on protected branches like main.

Server prep: On your VPS, ensure:

  • DEPLOY_USER exists and can write to DEPLOY_PATH
  • Your public key is in ~/.ssh/authorized_keys for that user

Step 4: Add a deploy job (rsync + restart)

We’ll use rsync to upload only changed files, and then restart a systemd service (optional). Add this to .gitlab-ci.yml:

deploy: stage: deploy image: alpine:3.20 needs: ["build"] before_script: - apk add --no-cache openssh-client rsync - mkdir -p ~/.ssh - echo "$SSH_PRIVATE_KEY" | tr -d '\r' > ~/.ssh/id_rsa - chmod 600 ~/.ssh/id_rsa # Optional: StrictHostKeyChecking=no is convenient but less secure. - printf "Host *\n\tStrictHostKeyChecking no\n" > ~/.ssh/config script: - rsync -az --delete dist/ "${DEPLOY_USER}@${DEPLOY_HOST}:${DEPLOY_PATH}/" # If you serve via nginx, this might be enough (static files). # If you run a Node service, restart it: - ssh "${DEPLOY_USER}@${DEPLOY_HOST}" "sudo systemctl restart myapp" rules: - if: $CI_COMMIT_BRANCH == "main" 

What’s happening:

  • needs: ["build"] downloads the build artifacts into the deploy job.
  • rsync --delete ensures removed files are also removed on the server (good for static builds).
  • The final systemctl restart is optional—use it if your app is a running service.

If your deploy user can’t run sudo systemctl restart myapp without a password, you have two options:

  • Configure a limited sudoers rule to allow only that restart command.
  • Skip restart for static sites served by nginx (no restart needed).

Optional: Make deployments safer with a “release” directory

Deploying directly into the live directory is simple, but you can reduce risk with a versioned release strategy:

  • Upload to a timestamped folder, e.g. /var/www/myapp/releases/2026-04-04_120000
  • Update a symlink /var/www/myapp/current to point to the new release
  • Serve from current

Here’s a deploy script snippet you can run over SSH:

ssh "${DEPLOY_USER}@${DEPLOY_HOST}" ' set -e RELEASES="'${DEPLOY_PATH}'/releases" CURRENT="'${DEPLOY_PATH}'/current" TS=$(date +%Y%m%d_%H%M%S) mkdir -p "$RELEASES/$TS" '

Then rsync to that release folder and switch the symlink:

rsync -az --delete dist/ "${DEPLOY_USER}@${DEPLOY_HOST}:${DEPLOY_PATH}/releases/${CI_PIPELINE_ID}/" ssh "${DEPLOY_USER}@${DEPLOY_HOST}" " ln -sfn '${DEPLOY_PATH}/releases/${CI_PIPELINE_ID}' '${DEPLOY_PATH}/current' sudo systemctl restart myapp " 

This makes rollback as easy as repointing the symlink to an older release.

Troubleshooting checklist

  • Pipeline is slow: confirm caching works (node_modules/ + .npm/). Consider caching only .npm/ and using npm ci for correctness.
  • “Permission denied (publickey)”: verify the private key variable, user, and that the public key is in authorized_keys.
  • Deploy job can’t write to the folder: fix ownership/permissions on DEPLOY_PATH.
  • Restart fails: ensure service name is correct and that the deploy user can restart it (sudoers rule).
  • Artifacts missing: confirm artifacts.paths matches your build output directory and that deploy has needs: ["build"].

Next upgrades (when you’re ready)

  • Add environment deployments (staging vs production) using GitLab environments.
  • Run tests on every branch push, but deploy only on main.
  • Add dependency scanning and SAST if your org uses GitLab security features.
  • Use a custom CI image with dependencies preinstalled to speed up jobs.

With this pipeline in place, you’ve turned “works on my machine” into a consistent process: every change is tested, built, and deployed the same way—without manual steps.


Leave a Reply

Your email address will not be published. Required fields are marked *