GitLab CI/CD runs pipelines defined in a .gitlab-ci.yml file at the root of your repository: every push creates a pipeline whose jobs run on GitLab Runners. In this tutorial you will write a pipeline for a Node.js front-end project with build, test and deploy stages, speed it up with cache and artifacts, and deploy the built site to an Ubuntu 24.04 server over SSH with rsync. At the end you will optionally register your own runner on a server.

Prerequisites

To follow this guide you need:

  • A project on GitLab.com or on a self-managed GitLab instance, with the Maintainer role so you can edit CI/CD settings.
  • Runners available for the project. GitLab.com provides hosted runners; on a self-managed instance, or if you prefer your own, see Step 6.
  • A Node.js project whose package.json has build and test scripts and whose build output goes to dist/ (Vite, for example). Adapt the commands if your project differs.
  • A server running Ubuntu 24.04, for example a CubePath VPS, with Nginx serving files from /var/www/your_app, a non-root user with sudo privileges and SSH access. This guide uses your_server_ip for its address.
  • Git installed on your local machine.

Step 1 - Creating a deploy user on the server

The pipeline should not log in with your personal account. Create a dedicated deploy user on the server that can only write to the site directory:

sudo adduser --disabled-password --gecos "" deploy
sudo mkdir -p /var/www/your_app
sudo chown deploy:deploy /var/www/your_app

Next, generate an SSH key pair for GitLab on your local machine. Leave the passphrase empty, because the pipeline cannot type one:

ssh-keygen -t ed25519 -C "gitlab-ci" -f ./gitlab-deploy-key -N ""

This creates gitlab-deploy-key (private) and gitlab-deploy-key.pub (public). Print the public key and copy the line:

cat gitlab-deploy-key.pub

Back on the server, create the deploy user's SSH directory and open its authorized_keys file:

sudo install -d -m 700 -o deploy -g deploy /home/deploy/.ssh
sudo nano /home/deploy/.ssh/authorized_keys

Paste the public key line, save the file and fix its ownership and permissions:

sudo chown deploy:deploy /home/deploy/.ssh/authorized_keys
sudo chmod 600 /home/deploy/.ssh/authorized_keys

From your local machine, test the key:

ssh -i ./gitlab-deploy-key deploy@your_server_ip 'whoami'
deploy

Finally, capture the server's host key so the pipeline can verify it instead of trusting any host:

ssh-keyscan -t ed25519 your_server_ip > gitlab-known-hosts
cat gitlab-known-hosts
203.0.113.10 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI...

Step 2 - Storing the SSH key as CI/CD variables

Secrets belong in project variables, never in .gitlab-ci.yml. In your GitLab project, go to Settings > CI/CD, expand Variables and add the following:

KeyTypeValueOptions
SSH_PRIVATE_KEYFileContents of gitlab-deploy-keyProtected
SSH_KNOWN_HOSTSFileContents of gitlab-known-hostsProtected
DEPLOY_HOSTVariableyour_server_ip

With the File type, GitLab writes the value to a temporary file at job runtime and puts the path of that file in the variable, which is what ssh -i expects. Protected variables are only passed to pipelines on protected branches and tags, so a merge request from a feature branch cannot read the key. The main branch is protected by default.

Delete the local key files once they are stored:

rm gitlab-deploy-key gitlab-deploy-key.pub gitlab-known-hosts

Step 3 - Writing the pipeline

In the root of your repository, create .gitlab-ci.yml:

nano .gitlab-ci.yml

Add the following pipeline:

stages:
  - build
  - test
  - deploy

default:
  image: node:22
  interruptible: true

workflow:
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS
      when: never
    - if: $CI_COMMIT_BRANCH

.npm_cache: &npm_cache
  cache:
    key:
      files:
        - package-lock.json
    paths:
      - .npm/

build:
  stage: build
  <<: *npm_cache
  script:
    - npm ci --cache .npm --prefer-offline
    - npm run build
  artifacts:
    paths:
      - dist/
    expire_in: 1 week

test:
  stage: test
  <<: *npm_cache
  needs: []
  script:
    - npm ci --cache .npm --prefer-offline
    - npm test

deploy:
  stage: deploy
  image: alpine:3.20
  needs:
    - build
    - test
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
  environment:
    name: production
    url: http://$DEPLOY_HOST
  resource_group: production
  interruptible: false
  script:
    - apk add --no-cache openssh-client rsync
    - chmod 600 "$SSH_PRIVATE_KEY"
    - >
      rsync -az --delete
      -e "ssh -i $SSH_PRIVATE_KEY -o UserKnownHostsFile=$SSH_KNOWN_HOSTS -o StrictHostKeyChecking=yes"
      dist/ "deploy@$DEPLOY_HOST:/var/www/your_app/"

Here is what each part does:

  • stages: jobs in the same stage can run in parallel; a stage starts when the previous one succeeds.
  • default: settings every job inherits. node:22 is the container image for the build and test jobs, and interruptible: true lets GitLab cancel an outdated pipeline when you push again.
  • workflow:rules: runs pipelines for merge requests and branches, but avoids a duplicate branch pipeline while a merge request is open for that branch.
  • .npm_cache: a hidden job (the leading dot means it never runs) reused through a YAML anchor. The cache key is derived from package-lock.json, so the npm cache is rebuilt only when dependencies change. npm ci --cache .npm stores downloads inside the project directory, because GitLab can only cache paths inside it.
  • artifacts: the dist/ directory from the build job is uploaded and passed to later jobs that need it. Use artifacts for results you pass between jobs, and cache for things that only speed up the next run.
  • needs: test: needs: [] starts testing right away, without waiting for the build stage. deploy waits for both jobs and downloads the build artifacts.
  • deploy:rules: only the default branch deploys. Merge request pipelines stop after testing.
  • environment and resource_group: GitLab records each deployment under Operate > Environments, and resource_group makes sure two deployments never run at the same time.
  • rsync --delete: copies only changed files and removes files that no longer exist in dist/, so the server matches the build exactly. StrictHostKeyChecking=yes makes the job fail if the host key does not match the one you stored.

Step 4 - Validating the configuration

Before committing, check the syntax in GitLab. Go to Build > Pipeline editor, paste or open the file and look at the status line at the top: it should say Pipeline syntax is correct. The Visualize tab shows the jobs and their dependencies, and Validate simulates a pipeline for the default branch, which is a quick way to confirm that deploy would run on main only.

Step 5 - Running the pipeline

Commit the file and push it to the default branch:

git add .gitlab-ci.yml
git commit -m "Add CI/CD pipeline"
git push origin main

Open Build > Pipelines in your project. The new pipeline shows three jobs; click any of them to follow its log. A successful deploy job ends like this:

$ chmod 600 "$SSH_PRIVATE_KEY"
$ rsync -az --delete -e "ssh -i $SSH_PRIVATE_KEY -o UserKnownHostsFile=$SSH_KNOWN_HOSTS -o StrictHostKeyChecking=yes" dist/ "deploy@$DEPLOY_HOST:/var/www/your_app/"
Cleaning up project directory and file based variables
Job succeeded

Verify on the server that the files arrived:

ls -l /var/www/your_app
total 8
drwxr-xr-x 2 deploy deploy 4096 Sep 24 11:02 assets
-rw-r--r-- 1 deploy deploy  462 Sep 24 11:02 index.html

Now open a merge request from a feature branch: its pipeline runs build and test but not deploy. On the second run of the default branch, the job log shows Restoring cache and Successfully extracted cache, and npm ci finishes noticeably faster.

Step 6 - Registering your own runner (optional)

If your GitLab instance has no hosted runners, or you want builds to run on your own hardware, install GitLab Runner on an Ubuntu 24.04 server with Docker installed. GitLab distributes the runner through its own apt repository, set up by a script. Download it and read it before running it:

curl -fsSL -o gitlab-runner-repo.sh https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.deb.sh
less gitlab-runner-repo.sh
sudo bash gitlab-runner-repo.sh
sudo apt install -y gitlab-runner

In GitLab, go to Settings > CI/CD > Runners, click New project runner, tick Run untagged jobs and create it. GitLab shows a runner authentication token that starts with glrt-. Register the runner with it, choosing the Docker executor:

sudo gitlab-runner register \
  --url https://gitlab.com \
  --token your_runner_token \
  --executor docker \
  --docker-image alpine:3.20

Use your instance URL instead of https://gitlab.com on a self-managed GitLab. Confirm that the runner can reach GitLab:

sudo gitlab-runner verify
Runtime platform                                    arch=amd64 os=linux ...
Verifying runner... is valid                        runner=AbCdEf12

The runner then appears with a green status on the Runners page and picks up jobs from your pipelines.

Troubleshooting

  • Load key "/builds/...tmp/SSH_PRIVATE_KEY": error in libcrypto: the key variable lost its trailing newline or was pasted incompletely. Edit the variable and paste the full key again, ending with a new line.
  • Host key verification failed: SSH_KNOWN_HOSTS does not contain the key of the host in DEPLOY_HOST. Run ssh-keyscan again against exactly the same address and update the variable.
  • The deploy job fails on a feature branch with empty variables: protected variables are not passed to unprotected branches. This is intended; deploy only from main, or protect the branch under Settings > Repository > Protected branches.
  • rsync: mkdir ... failed: Permission denied: the deploy user does not own /var/www/your_app. Fix it with sudo chown -R deploy:deploy /var/www/your_app.
  • Jobs stay in pending: no runner is available for the project. Enable hosted runners or register one as in Step 6.

Conclusion

You now have a GitLab pipeline that builds and tests every change, reuses its npm cache, passes the build output as an artifact and deploys the default branch to your server over SSH with a verified host key. As next steps, add a staging environment deployed from a separate branch, protect the production environment so deployments need approval, and extend the test stage with linting or GitLab's security scanning templates.