GitLab CI/CD is built into every GitLab project — on GitLab.com or your own instance. A .gitlab-ci.yml file in your repository defines stages that run on runners; the deploy stage can connect to your VPS over SSH and update the application. Here’s a secure, practical setup.
1. Prepare the VPS
Create a dedicated deploy user with only the access it needs:
sudo adduser --disabled-password --gecos "" deploy
sudo mkdir -p /var/www/app && sudo chown deploy:www-data /var/www/app
echo 'deploy ALL=(root) NOPASSWD: /usr/bin/systemctl restart myapp' | sudo tee /etc/sudoers.d/deploy
sudo chmod 440 /etc/sudoers.d/deploy
Generate a key pair for CI (on your computer):
ssh-keygen -t ed25519 -f gitlab_deploy -N "" -C "gitlab-ci"
Install the public key for the deploy user (/home/deploy/.ssh/authorized_keys, mode 600).
2. Add CI/CD variables
Project → Settings → CI/CD → Variables:
| Key | Value | Options |
|---|---|---|
SSH_PRIVATE_KEY |
contents of gitlab_deploy |
Type File, Protected |
SSH_KNOWN_HOSTS |
output of ssh-keyscan -t ed25519 your-server (verify the fingerprint!) |
Protected |
DEPLOY_HOST |
203.0.113.10 |
Protected |
Protected variables are only available to pipelines on protected branches/tags (like main), so a merge request from a fork can’t steal the key. A File-type variable gives you a path to a temporary file containing the key.
3. The pipeline
.gitlab-ci.yml:
stages:
- test
- build
- deploy
default:
image: node:22
cache:
key:
files: [package-lock.json]
paths: [.npm/]
test:
stage: test
script:
- npm ci --cache .npm --prefer-offline
- npm test
build:
stage: build
script:
- npm ci --cache .npm --prefer-offline
- npm run build
artifacts:
paths: [dist/]
expire_in: 1 week
.deploy_template: &deploy
stage: deploy
image: alpine:3.20
before_script:
- apk add --no-cache openssh-client rsync
- mkdir -p ~/.ssh && chmod 700 ~/.ssh
- cp "$SSH_PRIVATE_KEY" ~/.ssh/id_ed25519 && chmod 600 ~/.ssh/id_ed25519
- echo "$SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
deploy_production:
<<: *deploy
environment:
name: production
url: https://example.com
rules:
- if: $CI_COMMIT_BRANCH == "main"
when: manual
script:
- rsync -az --delete dist/ deploy@$DEPLOY_HOST:/var/www/app/
- ssh deploy@$DEPLOY_HOST "sudo systemctl restart myapp"
What’s happening:
- test and build run on every push;
buildsavesdist/as an artifact for later stages. - deploy_production runs only on
main, andwhen: manualadds a “play” button — a human confirms production deploys. Remove it for fully automatic deployments. environmentrecords deployments in GitLab’s Environments page, with history and a one-click redeploy of earlier versions (rollback).
Docker-based deploys
Build an image into GitLab’s built-in Container Registry and pull it on the server:
build_image:
stage: build
image: docker:27
services: [docker:27-dind]
script:
- docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" "$CI_REGISTRY"
- docker build -t "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA" .
- docker push "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA"
The deploy job then runs docker compose pull && docker compose up -d on the server with the new tag. The server needs a deploy token with read_registry scope to pull private images. See GitLab CI with Docker.
Runners
GitLab.com provides shared runners with a monthly compute allowance. For more minutes, private networks, or faster builds, run your own:
# on a separate VPS with Docker installed
docker run -d --name gitlab-runner --restart always \
-v /srv/gitlab-runner/config:/etc/gitlab-runner \
-v /var/run/docker.sock:/var/run/docker.sock \
gitlab/gitlab-runner:latest
docker exec -it gitlab-runner gitlab-runner register
Use a runner authentication token created under Settings → CI/CD → Runners. Keep runners on a separate machine from production — CI jobs execute code from merge requests. See installing GitLab Runner.
Tips
rules:replace the olderonly/exceptsyntax; use them to run jobs only for tags, branches or changed paths.needs:lets a job start as soon as its dependencies finish, speeding up pipelines.- Merge request pipelines test changes before merging.
- Lint your config in the GitLab UI (CI/CD → Editor → Validate).
Related: GitHub Actions deploy to VPS, self-hosting GitLab and deployment strategies.