# GitLab CI/CD: an automated deploy pipeline for your VPS

> Set up a GitLab CI/CD pipeline for automated VPS deploys over SSH: variables, stages, and instant rollback on failure.

GitLab CI/CD turns a manual deploy — "logged in over SSH and updated the files" — into a predictable pipeline: every push to a branch triggers a build, tests, and a rollout to the server with no human involved. Let's look at how to set up a working deploy pipeline to a plain VDS from scratch, including safely passing an SSH key and rolling back on failure.

## What CI/CD is and why it matters for deploying to a VPS

CI/CD stands for continuous integration and continuous delivery. In practice it is a `.gitlab-ci.yml` file at the root of the repository that describes stages: build the project, run the tests, deploy the code to the server. GitLab Runner executes these stages on every push or merge request automatically.

Without CI/CD, a deploy depends on a person: skip a step and you break production. With a pipeline the sequence of steps is the same every time, and the execution log is visible in the GitLab interface.

## A minimal .gitlab-ci.yml for deploying over SSH

A basic pipeline with two stages — `test` and `deploy` — looks like this:

```
stages:
  - test
  - deploy

run_tests:
  stage: test
  image: php:8.3-cli
  script:
    - php -l public/index.php

deploy_production:
  stage: deploy
  image: alpine:latest
  only:
    - main
  before_script:
    - apk add --no-cache openssh-client rsync
    - eval $(ssh-agent -s)
    - echo "$SSH_PRIVATE_KEY" | ssh-add -
    - mkdir -p ~/.ssh
    - ssh-keyscan -H $DEPLOY_HOST >> ~/.ssh/known_hosts
  script:
    - rsync -avz --delete ./app/ deploy@$DEPLOY_HOST:/var/www/app/
```

## How to safely pass an SSH key into GitLab CI

The private key is never stored in the repository. It is added under Settings → CI/CD → Variables as the `SSH_PRIVATE_KEY` variable with the Protected and Masked flags. Protected means the variable is only available on protected branches, Masked means the value never appears in plain text in the job log.

- The key is generated separately for CI rather than reusing a developer's personal key
- On the server, a separate `deploy` user with limited rights is created for this key
- In `authorized_keys` on the server, the key is bound with a `command=` restriction when access to only one script is needed

## Pipeline stages: build, test, deploy

The `build` stage assembles an artifact — for example, an archive with Composer dependencies or a built frontend. The `test` stage runs linters and unit tests and stops the pipeline on failure, keeping broken code off the server. The `deploy` stage copies the already checked artifact to the VDS.

| Stage | What it does | What happens on failure |
| --- | --- | --- |
| build | Builds dependencies and the deploy artifact | The pipeline stops, no deploy happens |
| test | Runs linters, unit and integration tests | The job is marked failed, deploy is blocked |
| deploy | Copies the artifact to the server, restarts services | The job log is visible, the release can be rolled back manually |

## Deploying with rsync and a release symlink

A reliable scheme is deploying into a separate dated folder and switching the `current` symlink only after a successful rollout. That way the site never looks at partially copied files, not even for a second.

```
ssh deploy@$DEPLOY_HOST "mkdir -p /var/www/releases/$CI_COMMIT_SHORT_SHA"
rsync -avz ./app/ deploy@$DEPLOY_HOST:/var/www/releases/$CI_COMMIT_SHORT_SHA/
ssh deploy@$DEPLOY_HOST "ln -sfn /var/www/releases/$CI_COMMIT_SHORT_SHA /var/www/current"
ssh deploy@$DEPLOY_HOST "systemctl reload php8.3-fpm"
```

## Rolling back a failed deploy

If a release breaks production, a rollback is switching the `current` symlink back to the previous folder in `/var/www/releases/`, not redeploying the old commit. Such a rollback takes seconds and needs no new pipeline run.

- Keep the last 5-10 releases on the server, not just the current one
- Keep a separate `rollback` job in `.gitlab-ci.yml`, triggered manually with a button in GitLab
- Log every deploy with a timestamp and commit hash into a separate file on the server

## Common mistakes in pipeline configuration

The first mistake is deploying without a test stage: the pipeline ships code to production that has not even been linted. The second is a shared SSH key across all environments at once, so compromising one key gives access to every server. The third is a forgotten `only` or `rules` clause on the deploy job, so the rollout runs from any branch instead of only `main`.

If the code also needs OS-level configuration after the deploy — packages, cron, configs — it makes sense to move that step into a separate [Ansible playbook](https://www.zevshost.net/en/kb/ansible-ili-terraform/) that the pipeline runs as the next job. For a comparison with an alternative CI platform, see the article on [deploying with GitHub Actions](https://www.zevshost.net/en/kb/github-actions-deploy-vps/). If the target server is a cluster of several machines, it is more convenient to point the deploy at [Docker Swarm](https://www.zevshost.net/en/kb/docker-swarm-nastroika/) instead of a single VDS.

## Summary: checklist for a working pipeline

A working VPS deploy through GitLab CI is built from four required elements, without which the pipeline will eventually fail you in production.

- A separate SSH key for CI with the Protected and Masked flags in variables
- A test stage before the deploy stage, not a direct deploy
- Releases in separate folders with a current symlink for an instant rollback
- Deploy restricted with an `only` or `rules` rule to the right branch

---

Source: https://www.zevshost.net/en/kb/gitlab-ci-deploy-vps/
