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
deployuser with limited rights is created for this key - In
authorized_keyson the server, the key is bound with acommand=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
rollbackjob 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
onlyorrulesrule to the right branch