Skip to main content

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

Cloud & DevOps · 29.09.2026

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.

StageWhat it doesWhat happens on failure
buildBuilds dependencies and the deploy artifactThe pipeline stops, no deploy happens
testRuns linters, unit and integration testsThe job is marked failed, deploy is blocked
deployCopies the artifact to the server, restarts servicesThe 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 that the pipeline runs as the next job. For a comparison with an alternative CI platform, see the article on deploying with GitHub Actions. If the target server is a cluster of several machines, it is more convenient to point the deploy at Docker Swarm 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
← Back to Knowledge Base Ask Support