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 lintnpm test(ornpm run test)npm run buildproducing adist/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 ciis preferred in CI: it uses your lockfile and is reproducible.cachemakes installs much faster on repeat pipelines.workflow.rulesprevents 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_USERexists and can write toDEPLOY_PATH- Your public key is in
~/.ssh/authorized_keysfor 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 --deleteensures removed files are also removed on the server (good for static builds).- The final
systemctl restartis 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
sudoersrule 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/currentto 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 usingnpm cifor 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.pathsmatches your build output directory and that deploy hasneeds: ["build"].
Next upgrades (when you’re ready)
- Add environment deployments (
stagingvsproduction) using GitLabenvironments. - 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