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; build saves dist/ as an artifact for later stages.
  • deploy_production runs only on main, and when: manual adds a “play” button — a human confirms production deploys. Remove it for fully automatic deployments.
  • environment records 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 older only/except syntax; 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.

  • GitLab CI
  • CI/CD
  • Deployment