--- title: "How to Build a GitLab CI/CD Pipeline That Deploys over SSH" description: "Write a .gitlab-ci.yml with build, test and deploy stages, use cache, artifacts and rules, and deploy a site to your Ubuntu 24.04 server over SSH with rsync." category: "CI/CD DevOps" language: en source: https://cubepath.com/docs/ci-cd-devops/gitlab-ci-cd-pipeline-configuration updated: 2026-09-25 --- # How to Build a GitLab CI/CD Pipeline That Deploys over SSH 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: ```bash 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: ```bash 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: ```bash cat gitlab-deploy-key.pub ``` Back on the server, create the `deploy` user's SSH directory and open its `authorized_keys` file: ```bash 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: ```bash sudo chown deploy:deploy /home/deploy/.ssh/authorized_keys sudo chmod 600 /home/deploy/.ssh/authorized_keys ``` From your local machine, test the key: ```bash ssh -i ./gitlab-deploy-key deploy@your_server_ip 'whoami' ``` ```output deploy ``` Finally, capture the server's host key so the pipeline can verify it instead of trusting any host: ```bash ssh-keyscan -t ed25519 your_server_ip > gitlab-known-hosts cat gitlab-known-hosts ``` ```output 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. > Important: Paste the private key including the `-----BEGIN` and `-----END` lines and make sure the value ends with a newline. Without it, SSH fails with `error in libcrypto`. Delete the local key files once they are stored: ```bash 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`: ```bash nano .gitlab-ci.yml ``` Add the following pipeline: ```yaml 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: ```bash 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: ```output $ 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: ```bash ls -l /var/www/your_app ``` ```output 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: ```bash 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: ```bash 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: ```bash sudo gitlab-runner verify ``` ```output 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.