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.jsonhasbuildandtestscripts and whose build output goes todist/(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 withsudoprivileges and SSH access. This guide usesyour_server_ipfor 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:
| Key | Type | Value | Options |
|---|---|---|---|
SSH_PRIVATE_KEY | File | Contents of gitlab-deploy-key | Protected |
SSH_KNOWN_HOSTS | File | Contents of gitlab-known-hosts | Protected |
DEPLOY_HOST | Variable | your_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.
ImportantPaste the private key including the
-----BEGINand-----ENDlines and make sure the value ends with a newline. Without it, SSH fails witherror in libcrypto.
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:22is the container image for the build and test jobs, andinterruptible: truelets 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 frompackage-lock.json, so the npm cache is rebuilt only when dependencies change.npm ci --cache .npmstores downloads inside the project directory, because GitLab can only cache paths inside it.artifacts: thedist/directory from thebuildjob 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.deploywaits for both jobs and downloads thebuildartifacts.deploy:rules: only the default branch deploys. Merge request pipelines stop after testing.environmentandresource_group: GitLab records each deployment under Operate > Environments, andresource_groupmakes sure two deployments never run at the same time.rsync --delete: copies only changed files and removes files that no longer exist indist/, so the server matches the build exactly.StrictHostKeyChecking=yesmakes 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_HOSTSdoes not contain the key of the host inDEPLOY_HOST. Runssh-keyscanagain 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: thedeployuser does not own/var/www/your_app. Fix it withsudo 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.