HostiServer
2026-08-18 11:17
GitLab CI self-hosted runner: install it on your VPS
📚 Series "CI/CD from scratch", part 4 of 6:
- What is CI/CD: from manual deploy to automated pipelines
- Self-hosted CI/CD: concept, architecture and runners
- GitHub Actions self-hosted runner: installation and the first pipeline on a VPS
- GitLab CI self-hosted runner: installation and the first pipeline on a VPS ← you are here
- Gitea Actions: a Git platform and a pipeline on one server
- GitHub Actions vs GitLab CI vs Gitea Actions in 2026: what to choose for self-hosted CI/CD
GitLab CI self-hosted runner: installation and the first pipeline on a VPS
In the third part we connected our own agent to GitHub Actions and brought the chain to a deploy to production over SSH. Now we walk the same path on GitLab CI: we install GitLab Runner on a VPS, register it in the project, write the first .gitlab-ci.yml and finish with the same deploy scenario. The scenario is deliberately repeated so that the difference between the platforms can be seen on an identical task, and not on two different examples.
The difference will turn out to be deeper than the syntax. In GitLab the pipeline is built on stages, a job is executed in a container by default, and the choice of executor determines how exactly your code is isolated. These three things influence the architecture of the solution more strongly than the look of the YAML.
1.1 What is needed at the start
- a project on GitLab: in the cloud on gitlab.com or in your own installation;
- a VPS with Ubuntu 22.04 or 24.04 and SSH access;
- the Maintainer or Owner role in the project: below it the page with the runners is not available.
Like the GitHub agent, GitLab Runner does not require open inbound ports. It addresses GitLab over port 443 itself and keeps the connection, waiting for jobs. That is why the runner calmly lives inside a closed perimeter next to production.
1.2 gitlab.com and your own runner: a combination that is often missed
A widespread misunderstanding: to get self-hosted CI on GitLab you supposedly have to bring up the whole of GitLab on your own. That is not so. The cloud gitlab.com and your own runner combine without restrictions: the repository, the interface, the merge request and the pipeline page remain in the cloud, and the build is executed on your server, with your resources and your access to the production network.
Three scenarios in which such a scheme is appropriate:
- The minute quota runs out. Cloud minutes are counted and they end, your own server is paid for at a fixed rate regardless of the number of builds.
- Non-standard resources are needed. A lot of RAM for a heavy build, a GPU for model tests, specific hardware which the cloud plans do not have.
- The deploy goes into a closed network. A cloud runner will not reach there, yours stands inside.
Your own GitLab installation (self-managed) is a separate decision with other reasons: requirements for storing the code, regulatory restrictions, working without access to the internet. It can be done later and independently, because the configuration of the pipeline and the runner does not change when moving.
ℹ️ About the names: GitLab CI is the pipeline system itself, built into GitLab since 2015. GitLab Runner is a separate program, written in Go, which executes the jobs. It is installed independently of GitLab and is updated on its own cycle.
2. GitLab CI architecture
Three concepts on which everything further rests: the configuration file, the hierarchy of the pipeline and the runner as a separate process.
2.1 .gitlab-ci.yml: one file in the root of the repository
Unlike GitHub Actions with the .github/workflows/ directory and an arbitrary number of files, GitLab looks for one file: .gitlab-ci.yml in the root. It describes the whole pipeline of the project.
The restriction is removed by two mechanisms. The first is include: the configuration can be split into parts and connected from other files, from other projects or by URL.
include:
- local: '/ci/build.yml'
- project: 'company/ci-templates'
ref: main
file: '/deploy/ssh.yml'
- template: Security/SAST.gitlab-ci.yml
The second is job templates through YAML anchors and the extends key, which remove repetitions inside the file. Together they give the same as what is achieved in GitHub with several workflows and reusable actions, only the entry point remains one file.
The path to the file is changed in Settings → CI/CD → General pipelines → CI/CD configuration file. There you can also specify a file from another project, if the configuration is centralized.
2.2 Stages → Jobs → Scripts
Stage is a logical group of jobs and the main difference from GitHub Actions. Stages are executed sequentially in the order declared in stages. The next one starts when the previous one has finished completely and successfully.
Job is a task inside a stage. Jobs of one stage are executed in parallel on different runners, so a stage lasts as long as the slowest job in it.
Script is the list of shell commands which the job executes. Here the difference is noticeable: in GitHub a step can be a ready-made action through uses, in GitLab a job is almost always commands. Instead of a marketplace of actions, container images and included templates are used.
stages: [build, test, deploy]
build-app: stage: build →┐
│ stage build
build-docs: stage: build →┘ (in parallel)
↓
unit-tests: stage: test →┐
│ stage test
lint: stage: test →┘ (in parallel)
↓
deploy-prod: stage: deploy → stage deploy
The strict order of stages can be bypassed with the needs key. It builds a dependency graph on top of the stages: a job starts right after the ones it depends on, without waiting for the rest of its stage. On large pipelines this noticeably shortens the total time.
deploy-staging:
stage: deploy
needs: [build-app] # does not wait for build-docs and for the whole test stage
script:
- ./scripts/deploy.sh staging
2.3 GitLab Runner as a separate process
GitLab Runner is an independent program which does not depend on GitLab and does not even have to stand next to it. Its work looks like this: once every few seconds it polls GitLab through the API for whether there is a job for its labels and its level of registration. Having received a job, it prepares the environment, executes the commands, streams the logs and returns the status.
Registration comes at three levels:
| Level | Who can use it | When to choose |
|---|---|---|
| Project | One project | The first runner, a deploy with access to a specific production |
| Group | All projects of the group | Shared builds of several services of the team |
| Instance | The whole installation | Only for self-managed GitLab |
A key property which GitHub Actions does not have: one runner executes several jobs at the same time. The concurrent parameter in the config sets how many jobs the runner process executes in parallel across all registered runners together, so one VPS replaces several separate agents.
3. Installing GitLab Runner on a VPS
3.1 Installing the package
The most convenient way is the official package repository:
# Debian / Ubuntu
curl -L "https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.deb.sh" | sudo bash
sudo apt install gitlab-runner
# RHEL / Rocky / AlmaLinux
curl -L "https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.rpm.sh" | sudo bash
sudo yum install gitlab-runner
The package immediately creates the system user gitlab-runner, the configuration in /etc/gitlab-runner/config.toml and a systemd service with autostart. That is, the step of setting up the service, which in GitHub Actions was done separately through svc.sh, is already done here.
The alternative is the binary, when the repositories are unavailable or a specific version is needed. Take the file for the required architecture from the GitLab Runner releases page: the direct addresses of the S3 bucket change from time to time, and commands from third-party instructions go stale quickly.
sudo curl -L --output /usr/local/bin/gitlab-runner "<link from the releases page>"
sudo chmod +x /usr/local/bin/gitlab-runner
sudo useradd --comment 'GitLab Runner' --create-home gitlab-runner --shell /bin/bash
sudo gitlab-runner install --user=gitlab-runner --working-directory=/home/gitlab-runner
sudo gitlab-runner start
The check after the installation:
gitlab-runner --version
sudo systemctl status gitlab-runner
⚠️ Version compatibility: the runner version must not run ahead of the GitLab version. For gitlab.com this is not relevant, because the cloud is updated first, but for a self-managed installation the rule works: first GitLab is updated, then the runner. A runner older than GitLab by several major versions starts losing support for new configuration keys.
3.2 Registering the runner
From version 16.0 GitLab moved to registration through a token created in the interface. The path: Settings → CI/CD → Runners → New project runner. There the labels (tags), the option to run jobs without tags and an optional description are set, after which GitLab shows a token of the form glrt-....
sudo gitlab-runner register \
--non-interactive \
--url "https://gitlab.com/" \
--token "glrt-XXXXXXXXXXXXXXXX" \
--executor "docker" \
--docker-image "alpine:latest" \
--description "vps-build-01"
The old way with --registration-token and the --tag-list key still turns up in documentation, but in modern versions it does not work: the tags are now set in the interface when creating the token, and not in the command.
After the registration the runner appears in the list with the online state, and its settings land in /etc/gitlab-runner/config.toml:
concurrent = 4
check_interval = 3
[[runners]]
name = "vps-build-01"
url = "https://gitlab.com/"
token = "glrt-XXXXXXXXXXXXXXXX"
executor = "docker"
[runners.docker]
image = "alpine:latest"
privileged = false
volumes = ["/cache"]
The file is re-read without restarting the service: after an edit the changes are applied by themselves. The concurrent parameter sets the number of parallel jobs for the whole agent, and it is worth starting with the number of cores divided by two.
3.3 Executor: shell or docker
The executor determines where exactly the commands of the job are executed. This is the main decision when setting things up, and changing it afterwards is inconvenient.
| Parameter | shell | docker |
|---|---|---|
| Where it is executed | Directly on the host | In a container from the job's image |
| Isolation | None | Processes and the file system are separated |
| Environment | Shared, accumulates state | Clean for each job |
The image key in the config |
Ignored | Works |
| Start speed | Instant | Seconds to create the container |
| What to install on the server | All languages and utilities manually | Only Docker |
The division comes out simple. Docker is taken by default for builds and tests: the environment is described by an image in the repository, the jobs leave no traces, the version of the language is changed with one line. Shell remains for deploy jobs which need access to the host itself: keys in ~/.ssh, configured rsync and ansible, access to local sockets.
These two modes do not compete. The working scheme is two runners on one VPS: one with the docker executor and the build label, the second with the shell executor and the deploy label. They are registered by separate register commands and live in one config.toml as two [[runners]] blocks.
⚠️ About privileged = true: this mode is needed for building images through Docker-in-Docker, and at the same time it gives the job root rights on the host. For a private repository with trusted code this is an acceptable compromise, for someone else's code it is not. A safer alternative is building images through Kaniko or Buildah in a mode without privileges.
4. The first .gitlab-ci.yml
4.1 Stages
The file begins with the list of stages. The order in the list is the order of execution:
stages:
- build
- test
- deploy
A job without the stage key falls into the test stage. This is the default behaviour, which sometimes produces surprises, so it is better to specify the stage explicitly.
4.2 A basic job
stages: [build, test, deploy]
default:
image: node:20
tags: [self-hosted, docker]
interruptible: true
variables:
npm_config_cache: "$CI_PROJECT_DIR/.npm"
build:
stage: build
script:
- npm ci --cache .npm --prefer-offline
- npm run build
cache:
key:
files:
- package-lock.json
paths:
- .npm/
artifacts:
paths:
- dist/
expire_in: 1 week
lint:
stage: test
script:
- npm ci --cache .npm --prefer-offline
- npm run lint
unit-tests:
stage: test
script:
- npm ci --cache .npm --prefer-offline
- npm test -- --ci
artifacts:
when: always
reports:
junit: reports/junit.xml
What matters here:
tagsis the analogue ofruns-onin GitHub. The job will go to a runner which has all the listed labels. A mismatch here is the most frequent cause of a job stuck in the pending state.defaultsets shared settings for all jobs, so as not to repeatimageandtagsin each of them.interruptible: trueallows cancelling outdated runs when a newer commit has arrived in the branch. Together with the enabled project option this is the analogue ofconcurrencyin GitHub Actions.artifacts: reportsis a built-in mechanism which GitHub does not have: GitLab parses the report and shows the test results right in the merge request.
4.3 Run rules: rules instead of only
The only/except key still works, but its development has been stopped, and in a new configuration rules is used:
deploy-production:
stage: deploy
script:
- ./scripts/deploy.sh
rules:
# do not run for changes in the documentation only
- if: $CI_COMMIT_BRANCH == "main"
changes:
paths: ["docs/**/*", "*.md"]
when: never
# in main with manual confirmation
- if: $CI_COMMIT_BRANCH == "main"
when: manual
allow_failure: false
# in the remaining cases the job is not in the pipeline
- when: never
The rules are checked from top to bottom, the first one that matched is applied. The value when: manual creates a job with a run button, and allow_failure: false makes it blocking: until the button is pressed, the pipeline is not considered finished. This is the manual step of Continuous Delivery.
4.4 Artifacts between jobs
Artifacts in GitLab are passed between stages automatically: a job of the deploy stage receives the artifacts of all the previous stages without an explicit download. In GitHub Actions separate upload-artifact and download-artifact steps are needed for this.
build:
stage: build
artifacts:
paths: [dist/]
expire_in: 1 week
deploy:
stage: deploy
script:
- ls dist/ # the files are already in place
# narrow down the list: take the artifacts of one job only
dependencies: [build]
The dependencies key is worth setting consciously. Without it a job pulls the artifacts of all the previous stages, and on a large pipeline this is tens of needless megabytes for every job.
ℹ️ Artifacts and cache are different things. An artifact is a result needed by the following jobs: if it disappears, the pipeline breaks. Cache is an optimization of repeated runs: if it disappears, the build simply goes slower. The reference point from the first part of the series remains valid: cache what can be restored from the network, and put into artifacts what was created by your pipeline itself.
5. CI/CD Variables
GitLab has no separate storage for secrets, like Secrets in GitHub. Instead there are variables with flags which determine the level of protection.
5.1 Where the variables live
The path: Settings → CI/CD → Variables. Each variable has a key, a value, a type (Variable or File) and a set of flags.
The File type solves the task which in GitHub is solved manually: GitLab puts the value into a temporary file and substitutes the path to it into the variable. For SSH keys and configurations this removes the steps with echo into a file and at the same time the problems with line breaks.
5.2 Protected and Masked: the difference
The two flags solve different tasks, and they are confused constantly.
| Flag | What it does | What it protects from |
|---|---|---|
| Protected | The variable is available only in jobs from protected branches and tags | From access to production secrets from any branch |
| Masked | The value is replaced with asterisks in the job logs | From an accidental output into the log |
The main thing here: Masked without Protected does not protect from anything serious. Anyone who can create a branch writes a job in it with cat over the variable in base64 and gets the value bypassing the masking. The protection is given exactly by Protected together with the setting of protected branches in Settings → Repository → Protected branches.
The masking has technical requirements: a value from 8 characters, without spaces and line breaks, only characters from a limited set. A multi-line SSH key cannot be masked, and that is exactly why the File type together with the Protected flag is taken for it.
⚠️ Merge requests from forks: protected variables are unavailable for them by default, and this is correct behaviour. Separately check the setting Settings → CI/CD → General pipelines → Run pipelines for merge requests from forks: in combination with a self-hosted runner it means that someone else's code will be executed on your server.
5.3 Variables at the group level
If several projects are deployed to the same infrastructure, it is better to keep the variables at the group level: Group → Settings → CI/CD → Variables. They are inherited by all the projects of the group, and a change is made in one place.
The order of priority for identical names, from the highest to the lowest:
- variables set at a manual run of the pipeline;
- project variables;
- group variables (a nested group has priority over the parent one);
- instance variables;
- variables from
.gitlab-ci.yml.
The practical conclusion: keep the shared values in the group, and let individual projects override what differs in them, with the same name. The configuration in .gitlab-ci.yml stays identical for everyone at that.
One more level is Environments with a scope: one DEPLOY_HOST variable has different values for staging and production, and the job receives the needed one through the environment key. The logic is the same as in the Environment secrets from the previous part.
6. A practical example: deploy to a server over SSH
6.1 The same scenario
The conditions completely repeat the example from the third part: after a push to main the project is built, the artifact goes to the production server through rsync, the service is restarted, after which the health check is verified. On the server there is already a deploy user without root privileges, the key is bound by the from parameter to the address of the runner, and the restart of the service is allowed pointwise through sudoers.
The variables in Settings → CI/CD → Variables:
SSH_PRIVATE_KEY— type File, the Protected flag. The File type here removes all the manual work with line breaks.SSH_KNOWN_HOSTS— type File, Protected. The content is the output ofssh-keyscan -H your-server.example.com.DEPLOY_HOST— an ordinary variable with the address of the server.
6.2 The full .gitlab-ci.yml
stages: [build, test, deploy]
default:
interruptible: true
variables:
npm_config_cache: "$CI_PROJECT_DIR/.npm"
build:
stage: build
image: node:20
tags: [self-hosted, docker]
script:
- npm ci --cache .npm --prefer-offline
- npm run build
cache:
key:
files: [package-lock.json]
paths: [.npm/]
artifacts:
paths: [dist/]
expire_in: 1 week
test:
stage: test
image: node:20
tags: [self-hosted, docker]
script:
- npm ci --cache .npm --prefer-offline
- npm run lint
- npm test -- --ci
artifacts:
when: always
reports:
junit: reports/junit.xml
deploy-production:
stage: deploy
tags: [self-hosted, shell] # shell executor: access to the host is needed
environment:
name: production
url: https://app.example.com
dependencies: [build]
timeout: 10 minutes
rules:
- if: $CI_COMMIT_BRANCH == "main"
when: manual
allow_failure: false
before_script:
# option 1, the File variable type: GitLab has already put the key into a temporary file
- chmod 600 "$SSH_PRIVATE_KEY"
# option 2, the key in an ordinary variable — as in GitHub Actions:
# - install -m 700 -d ~/.ssh
# - install -m 600 /dev/null ~/.ssh/deploy_key
# - echo "$SSH_PRIVATE_KEY_RAW" > ~/.ssh/deploy_key
# - echo "$SSH_KNOWN_HOSTS_RAW" > ~/.ssh/known_hosts
# after_script:
# needed only for option 2: the File type is deleted by the platform
# - rm -f ~/.ssh/deploy_key
after_script:
# the shell executor keeps the working directory between jobs: we remove the key explicitly
- shred -u "$SSH_PRIVATE_KEY" 2>/dev/null || rm -f "$SSH_PRIVATE_KEY"
script:
- |
rsync -az --delete \
-e "ssh -i $SSH_PRIVATE_KEY -o UserKnownHostsFile=$SSH_KNOWN_HOSTS" \
dist/ deploy@$DEPLOY_HOST:/var/www/app/
- |
ssh -i "$SSH_PRIVATE_KEY" -o UserKnownHostsFile="$SSH_KNOWN_HOSTS" \
deploy@$DEPLOY_HOST "sudo systemctl restart app.service"
- |
for i in $(seq 1 10); do
code=$(curl -s -o /dev/null -w "%{http_code}" https://app.example.com/health || true)
[ "$code" = "200" ] && exit 0
sleep 3
done
echo "The service does not respond after the deploy"
exit 1
In the deploy job two ways of working with the key are shown deliberately. The first, working by default, relies on the File variable type: GitLab itself creates a temporary file and removes it when cleaning the working directory of the job, so there are no steps with echo into a file. Since the shell executor keeps the working directory between runs, the key is removed explicitly in after_script. The second, commented out, repeats the approach from the third part: the key lies in an ordinary variable, the file is created manually, and what has to be removed is already ~/.ssh/deploy_key. It will be needed when migrating from GitHub Actions, when the config is carried over as is and the syntax is changed gradually.
The difference here is not in the capabilities of the platforms, but in who does the routine work: the File type takes away the creation of the file and the work with line breaks, while the cleanup remains on you in both options. On a permanent runner this has practical weight, because a forgotten cleanup step leaves the private key available to the next job.
The environment key gives one more effect, besides the scope of the variables. GitLab keeps a history of deploys on the Deployments → Environments page: it is visible which commit is on production now, when it got there and who started the job. There is also a button there for rolling back to the previous successful deploy, which simply restarts the same job with the old commit.
ℹ️ Two labels on one server: the build and test jobs go to the runner with the docker executor, the deploy job to the runner with the shell executor. Both stand on the same VPS, differ by labels and live in one config.toml. This way the build is executed in a clean container, and the deploy gets access to the keys and the network of the host, without privileged containers.
7. Conclusion
We walked the same path as in the third part, but on GitLab CI. The main things from practice:
- Cloud GitLab and your own runner combine freely. Bringing up self-managed GitLab for the sake of self-hosted CI is not necessary.
- The executor is the main decision when setting things up. Docker for builds and tests, shell for the deploy, both on one server with different labels.
- Stages set the order,
needsspeeds it up. The dependency graph removes the waiting where it is not needed. - Masked without Protected is not protection. The secret is closed exactly by the Protected flag together with protected branches.
- The File type removes the manual work with keys. The temporary file is created and deleted by the platform, so forgetting to remove the key is impossible.
The difference from GitHub Actions adds up into an understandable picture. GitLab gives more that is built in: stages, test reports in the merge request, a history of deploys with a rollback, parallel jobs on one agent. GitHub gives more that is ready from the outside: the marketplace of actions closes typical steps with one line. The first is more valuable for complex pipelines, the second for a quick start.
What comes next in the series
In the fifth part we will make the step after which nothing goes outside at all: Gitea Actions, where the git platform and the pipeline live on one server of yours. The syntax there almost repeats GitHub Actions, so the configurations from the third part are carried over almost without edits, but the server now holds both the git platform and the pipeline, so the requirements for resources and administration are different. A comparison of all three platforms with an answer to the question of what to choose for specific conditions awaits in the sixth part.
📚 Series navigation:
You are reading part 4 of 6 "GitLab CI self-hosted runner: installation and the first pipeline on a VPS".
Previous: ← Part 3. GitHub Actions self-hosted runner: installation and the first pipeline on a VPS
Next: Part 5. Gitea Actions: a Git platform and a pipeline on one server →
🚀 VPS and dedicated servers for your GitLab Runners
One runner executes several jobs at the same time, and each of them runs up against the CPU, the disk and the network. Hostiserver provides predictable resources for this and a network from which you can safely reach production.
🖥️ Dedicated Servers
- From $90/mo, full control over the hardware and a high
concurrentwithout queues - NVMe drives: a fast cache of dependencies and Docker layers between runs
- No build minute limits: you pay for the server, not for a GitLab quota
- A private network between the runner and the production servers
- 24/7 support: engineers will help with setting up the runners and the deploy
💻 Cloud (VPS) Hosting
- From $19.95/mo, KVM isolation, dedicated vCPU and RAM
- Ideal for the first runner: docker for builds and shell for the deploy on one machine
- Easy to scale: add a server for builds when
concurrentstops coping
💬 Not sure which option you need?
💬 Write to us and we will help with everything!
Frequently asked questions
- Do you need your own GitLab in order to have a self-hosted runner?
No. The cloud gitlab.com connects your runner without restrictions: the repository, the merge request and the pipeline page remain in the cloud, and the builds are executed on your server. Your own GitLab installation is needed for other reasons: requirements for storing the code, working without access to the internet, full control over the data. This is a separate decision, and the configuration of the pipeline does not change when moving to self-managed.
- The shell or the docker executor: which to choose for the first runner?
Docker for builds and tests, because the environment is described by an image in the repository and the jobs leave no traces on the server. Shell for deploy jobs which need access to the keys and the network of the host. The most convenient thing is to register both on one VPS with different labels:
dockerandshell. An attempt to do everything through shell ends with a server on which five versions of Node.js have gradually been installed, and doing everything through docker with the privileged mode gives the job root rights on the host.
- How does Protected differ from Masked?
Protected limits the access: the variable is visible only to jobs from protected branches and tags. Masked hides the value in the logs, replacing it with asterisks. The protection from a leak is given exactly by Protected: without it anyone who can create a branch will print the value in base64 and bypass the masking. For production secrets enable Protected without fail, Masked in addition. A multi-line SSH key cannot be masked because of the technical requirements, so the File type is taken for it.
- The job hangs in the pending state and does not start. What to check?
The most frequent cause is a mismatch of the labels: the job specifies
tagswhich no online runner has. The second most widespread is a job without tags: such jobs are taken only by a runner with the corresponding option enabled, and by default it is disabled. Further check the status of the agent (sudo gitlab-runner statusandsudo gitlab-runner verify), an exhaustedconcurrentand, for protected variables, whether the branch is protected. The logs of the agent are viewed throughjournalctl -u gitlab-runner -f.
- How many jobs does one VPS pull and how to configure
concurrent? The
concurrentparameter in/etc/gitlab-runner/config.tomlsets the number of parallel jobs for all the registered runners together. The limiting factor here is more often not the processor but the memory: count it by the heaviest job multiplied byconcurrent, because a frontend build easily eats 2 GB. Four simultaneous builds on a dual-core VPS go slower than two sequential ones. The value is changed in the file and is applied without restarting the service.
- How to carry the configuration over from GitHub Actions to GitLab CI?
There is no automatic transfer, but the correspondence is direct:
jobsbecome jobs,runs-onturns intotags,stepswithrunland inscript,needsworks the same way. It is more difficult with theusessteps: there are no ready-made actions in GitLab, so each of them is replaced either with commands inscriptor with the corresponding container image. GitLab has a built-in import of projects from GitHub together with the history and the merge requests, but the.gitlab-ci.ymlfile is written anew.
- Can a deploy be rolled back by GitLab's own means?
Yes, if the deploy job has the
environmentkey. On the Deployments → Environments page the history is kept: which commit is on production now, when it got there and who started the job. The Rollback button restarts the same job with the old commit. This works exactly to the extent that your deploy is idempotent: deploying static files or an image is rolled back without problems, but database migrations are not rolled back by this mechanism and require a separate plan.