GitLab CI/CD deploy to server: SSH setup, runners, compute minutes and rollback
To deploy to a server from GitLab CI/CD, add the server's SSH private key as a File type CI/CD variable, mark it protected so only pipelines on protected branches can read it, load it into ssh-agent in a before_script, and run the deploy job with an environment keyword so GitLab records the deployment. GitLab Free includes 400 compute minutes a month, Premium 10,000 and Ultimate 50,000, and self managed runners consume none of that quota. The rollback button on the environments page re-runs an older deployment job rather than switching instantly to a release that is still on disk, so instant rollback is still something you build.
Skip the manual setup. DeployManage provisions your server and ships zero-downtime deploys on any cloud.
$ get started freeGitLab CI/CD will happily deploy to a Linux server you own. The pipeline is not complicated, and if your code already lives on GitLab there is a strong argument for not adding another tool. The parts people get wrong are all in the details: which variable type holds the SSH key, what happens to that key on an unprotected branch, how many compute minutes the job actually burns, and what the rollback button on the environments page really does.
This walks through a pipeline that works on a real server, using only things GitLab documents, and then marks the point where a deployment tool starts to earn its keep.
How do I deploy to a server using GitLab CI/CD?
Four moving parts, in this order: give the runner an SSH key the server trusts, connect to the server, put the new code somewhere, then switch to it and reload the application. Everything else in a deploy pipeline is a variation on those four.
There are two shapes for the middle step. Either the runner builds the application and pushes the result to the
server with rsync, or the runner tells the server to pull the commit itself using a deploy key
installed on the box. Push is easier to reason about when the build needs Node or a compiler you would rather not
install on production. Pull keeps credentials off the runner and is usually what you want on a small team.
Setting up SSH keys for GitLab CI/CD deployment
Generate a dedicated key pair for deployment. Not your personal key, not a key a human also uses. Put the public
half in ~/.ssh/authorized_keys for the deploy user on the server, and the private half into GitLab.
The private half belongs in Settings, CI/CD, Variables, and the variable type matters. GitLab's
documentation is explicit that sensitive values like tokens and passwords should be stored in the settings UI, not
in .gitlab-ci.yml. Use a File type variable rather than a plain one: GitLab writes the
value to a temporary file and exposes the path as the environment variable, which is exactly what
ssh-add and other file-hungry tools expect. A multi-line PEM key stuffed into a regular variable is
where most broken pipelines start.
Two flags on that variable do real work:
- Protected. A protected variable is available only to pipelines running on protected branches or protected tags. Turn this on and a merge request from a fork or a scratch branch cannot read your production key. This is the single most important checkbox on the page and it is off by default.
- Masked. Masking replaces the value with
[MASKED]in job logs. GitLab documents firm requirements for a maskable value: it has to be a single line, contain no spaces, be at least 8 characters, and not collide with an existing variable name. A PEM private key is multi-line, so it cannot be masked. That is another reason to use the File type and neverechoit. GitLab also warns that if a process outputs the value in a slightly modified way, such as through shell escaping, masking will not catch it.
If you need to ship certificates or keystores alongside the key, GitLab's Secure Files feature stores up to 100 files per project at 5 MB each, encrypted on upload. Worth knowing: secure file contents are not automatically masked in job logs either, so the same discipline applies.
The job itself loads the key into an agent and pins the host key so the first connection is not a blind trust prompt:
deploy:
stage: deploy
environment:
name: production
url: https://example.com
only:
- main
before_script:
- which ssh-agent || apt-get update -y && apt-get install openssh-client -y
- eval $(ssh-agent -s)
- chmod 600 "$SSH_PRIVATE_KEY"
- ssh-add "$SSH_PRIVATE_KEY"
- mkdir -p ~/.ssh && chmod 700 ~/.ssh
- ssh-keyscan -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts
script:
- ssh deploy@"$DEPLOY_HOST" "cd /var/www/app && ./deploy.sh $CI_COMMIT_SHA"
Note that $SSH_PRIVATE_KEY here is a path, because it is a File type variable. If you copied a
snippet that pipes the variable into ssh-add -, that snippet assumes the other variable type and will
fail confusingly.
One caution on ssh-keyscan: it trusts whatever answers on the first run. For a server you just
provisioned that is usually fine. For anything that matters, capture the host key fingerprint once out of band and
store it as its own variable, then write it into known_hosts instead of scanning.
Using the environment keyword so GitLab knows what is deployed
The environment block above is not decoration. It is what turns a job that happens to run some SSH
commands into something GitLab tracks as a deployment. Once it is there, GitLab keeps a deployment history for that
environment, records the environment status and URL, lets you scope CI/CD variables to that environment, and gives
you a deployments list you can act on.
It also unlocks protected environments, which restrict deployment to people on an explicit "Allowed to deploy" list. This is the control most teams reach for the first time somebody deploys to production by accident. If you are in a regulated shop, the allow list plus the deployment history is usually the artifact an auditor wants, and it maps cleanly onto a change management control if you are tracking which controls your evidence satisfies for SOC 2 or ISO 27001.
Is GitLab CI/CD free, and how many compute minutes will a deploy use?
GitLab CI/CD is free to use, with a monthly quota on GitLab hosted runners. These figures are from GitLab's own pricing and compute minutes documentation, checked in August 2026:
| Tier | Price | Compute minutes a month | Storage |
|---|---|---|---|
| Free | 0 USD per user | 400 | 10 GiB |
| Premium | 29 USD per user a month, billed annually | 10,000 | 500 GiB |
| Ultimate | Custom, contact sales | 50,000 | 500 GiB |
The important line in that documentation is the one about runners: self managed runners do not consume compute minutes. The quota applies to GitLab hosted instance runners only. If you are already paying for a server, registering a small runner on it makes your deployment pipeline effectively free of quota concerns, and it also removes the network hop when the runner and the target are in the same place.
For a typical deploy job, expect somewhere between one and five minutes depending on whether you build assets on the runner. Four hundred minutes goes further than people assume for deployment alone. It disappears fast the moment you add a full test suite on every merge request, which is the actual reason most teams outgrow the free quota.
Deploying to multiple servers from one pipeline
Once you have more than one web node, the naive approach (a loop over hostnames inside one script) starts biting you, because a failure halfway leaves half your fleet on the new code and half on the old.
A cleaner shape is a parallel:matrix block that fans one deploy job out per host, so each target
gets its own job, its own log and its own retry:
deploy:
stage: deploy
parallel:
matrix:
- DEPLOY_HOST: [web1.example.com, web2.example.com]
script:
- ssh deploy@"$DEPLOY_HOST" "cd /var/www/app && ./deploy.sh $CI_COMMIT_SHA"
That gives you visibility, not safety. Nothing here stops the pipeline from leaving the fleet in a mixed state, and nothing takes a node out of the load balancer before its release flips. Genuinely safe multi node deployment means draining a node, deploying it, health checking it and returning it to rotation before touching the next one, which is a rolling deployment and is a meaningful amount of pipeline code to write and keep working.
What the GitLab rollback button actually does
This is the detail that surprises people at the worst possible moment. GitLab's environments page does offer rollback: you can select which deployment to roll back to from the environment's deployment list. What that does is re-run the deployment job for that older commit.
Re-running is not the same as reverting. It means your rollback takes as long as a full deploy, including whatever build and install steps live in that job. If the reason you are rolling back is that the site is down, you are now waiting several minutes with the site still down. It also means the rollback can fail for reasons unrelated to your code: a package registry that is slow today, a dependency that has since been yanked, a build container that has moved on.
GitLab does offer Auto Rollback, which triggers a rollback automatically when a critical alert fires, but that feature is Ultimate tier.
The alternative is the release directory model. Each deploy checks the commit into a fresh timestamped directory, builds inside it, and only then moves a symlink. The previous release is still sitting on disk, complete. Rolling back is moving the symlink back and reloading the process, which takes about as long as it takes to type, and cannot fail on a network call. That is what a zero downtime deployment setup buys you, and it is the specific gap between a CI runner and a deployment tool.
GitLab CI/CD versus GitHub Actions for deploying to a server
Functionally these are closer than the tribal argument suggests. Both run containers, both hold secrets, both can SSH into a box, and both leave the definition of a release entirely up to you.
The differences that matter for deployment specifically: GitLab's environment keyword and protected
environments give you deployment tracking and an access list out of the box, which
GitHub Actions deployment approximates with environments and
required reviewers but wires up differently. GitLab's free quota is 400 minutes against Actions' 2,000 on the free
tier, though Actions is unlimited on public repositories. And self hosting a runner is a first class, well trodden
path on GitLab, where GitHub explicitly recommends against self hosted runners on public repositories.
Neither one gives you an atomic release, a health check that can veto a deploy, or an instant rollback. If you are choosing between them for a server deployment, pick whichever one your code already lives next to, and be aware you are signing up to write the release layer either way.
When a pipeline stops being the right tool
A GitLab pipeline is the right answer for a long time. It is free, it is next to your code, and for a single server with a simple stack it will do the job for years.
The point where it stops paying for itself is fairly specific. It is when the YAML has grown a health check somebody wrote, a rollback path nobody has tested, a supervisor restart with a sleep in front of it, and a comment explaining why the migration step runs where it does. At that point you are maintaining a deployment product inside a CI config, and the pipeline that was meant to save time is a thing that needs its own time.
The alternative is to let CI do what it is good at, which is running tests and building artifacts, and hand the release itself to something that treats a release as a first class object. That means deploying from Git into timestamped release directories, gating the switch on the application actually answering, restarting queue workers in an order that does not drop jobs, and keeping the previous release on disk so rollback is a symlink move. GitLab still triggers it. It just stops being responsible for defining what a deployment means.
Ready to stop managing servers by hand? DeployManage provisions, deploys and monitors your fleet from one dashboard.
$ get started freeFrequently asked questions
How do I deploy to a server using GitLab CI/CD?
Add the deploy user's SSH private key as a File type CI/CD variable and mark it protected, load it into ssh-agent in a before_script, pin the host key into known_hosts, then run your deploy commands over SSH in the script block. Add an environment keyword so GitLab records the run as a deployment with its own history.
Is GitLab CI/CD free?
Yes, with a quota on GitLab hosted runners. The Free tier includes 400 compute minutes a month and 10 GiB of storage at no per user cost. Premium is 29 USD per user a month billed annually with 10,000 minutes, and Ultimate includes 50,000. Self managed runners do not consume the compute quota at all.
What variable type should I use for an SSH private key in GitLab CI?
Use a File type variable. GitLab writes the value to a temporary file and exposes the path as the environment variable, which is what ssh-add expects. A multi-line PEM key in a regular variable is the most common cause of a failing deploy job, and a multi-line value cannot be masked in logs.
What is the difference between a protected and a masked variable in GitLab?
Protected means the variable is only available to pipelines running on protected branches or tags, which keeps production credentials away from feature branches and forks. Masked means the value is replaced with [MASKED] in job logs. Masking requires a single line value with no spaces, at least 8 characters, that does not collide with an existing variable name.
How do I deploy to multiple servers from one GitLab pipeline?
Use a parallel matrix block that fans one deploy job out per hostname, so each target gets its own job, log and retry. That gives visibility but not safety: nothing stops a partial failure leaving your fleet on mixed versions. Safe multi node deployment means draining, deploying, health checking and returning each node in turn.
Can GitLab roll back a deployment?
Yes, but it re-runs the deployment job for the older commit rather than switching to a release that is still on disk. That means a rollback takes as long as a full deploy and can fail on a slow registry or a yanked dependency. Auto Rollback on a critical alert exists but is limited to the Ultimate tier.
Should I use a GitLab hosted runner or a self managed runner for deployment?
Self managed, in most cases. Self managed runners consume none of your monthly compute minutes, and running one on infrastructure near the target removes a network hop. Use GitLab hosted runners when you would rather not maintain a runner host, and expect a deploy job to cost roughly one to five minutes.
Is GitLab CI/CD better than GitHub Actions for deploying to a server?
They are close. GitLab gives you deployment tracking and protected environments with an allowed to deploy list out of the box, and self hosted runners are a well trodden path. GitHub Actions has a larger free quota at 2,000 minutes and is unlimited on public repositories. Neither provides atomic releases, health gated deploys or instant rollback.
Do I still need a deployment tool if I have GitLab CI/CD?
Only once the pipeline starts defining what a release is. A pipeline that runs a build and an SSH command is fine. A pipeline carrying a hand written health check, an untested rollback path and a supervisor restart with a sleep in front of it has become a deployment product maintained inside a CI config.
Is it safe to use ssh-keyscan in a GitLab deploy job?
It is convenient rather than safe, because it trusts whatever answers on the first connection. For a freshly provisioned server that is usually acceptable. For anything that matters, capture the host key fingerprint once out of band, store it as its own CI/CD variable, and write it into known_hosts instead of scanning at deploy time.