CI/CD on your own VPS
A dedicated build server pays off when hosted CI minutes run out, builds are slow or jobs need access to internal resources. But a runner executes any code in the repository, so a mistake in its setup exposes the production server and every secret. Here is how GitLab and GitHub runners work, what resources they need, how to deploy over SSH and where to keep the keys.
CI/CD means building, testing and releasing code automatically after every change. The pipeline is described in the repository (.gitlab-ci.yml or files in .github/workflows/) and executed by a runner, a program that picks up jobs from GitLab or GitHub and runs them. You can use the service's runners or install one on your own VPS.
Self-hosted runners: GitLab and GitHub
GitLab Runner is installed from the official repository (installation guide) and registered with a project or group using a token from the CI/CD settings. The main choice at registration is the executor. shell runs commands directly on the server's system, while docker runs each job in a fresh container. According to the GitLab documentation, the shell executor gives no clean environment per build, and jobs can see the whole file system, including the runner token and other projects' caches. The docker executor suits most projects.
A self-hosted GitHub Actions runner is added in the repository or organisation settings (GitHub documentation). Using it is free; you pay only for the server. The runner connects to GitHub itself over HTTPS on port 443, so you do not need to open any inbound ports. If a runner has not been updated for 30 days, GitHub stops sending it jobs (self-hosted runners reference).
An important limitation for open-source projects. GitHub states that self-hosted runners should almost never be used with public repositories: anyone can open a pull request and run their own code on your server. Keep hosted runners for public code and connect your own server to private repositories.
Why builds and production need separate servers
- Load. A frontend build or a compilation occupies every core for minutes. If the runner lives on the site's server, the site responds more slowly while it runs.
- Security. A runner executes code from any branch, including a branch pushed by a contractor. According to the GitLab documentation, the shell executor carries a high risk for the server and the network, and a container in privileged mode gives the job full root on the host. If a site with a customer database runs on the same server, every job can reach that database.
- Disk space. Docker images, dependency caches and artifacts take tens of gigabytes. When the disk fills up, not only the build stops but the site as well.
A setup for a small team: one VPS for the runner, another for the production site and, if needed, a third for a staging environment (see the article VPS for developers: staging environments). The runner delivers only the finished build to the production server.
How many resources a build needs
GitHub's hosted runners are a good yardstick. According to its reference, the standard Linux runner for private repositories has 2 cores, 8 GB of memory and a 14 GB SSD, and the one for public repositories has 4 cores and 16 GB. If your builds fit within these resources, a VPS of the same size will do no worse, and with a fast disk and a persistent cache it is usually faster.
- Cores. A build with parallel compilation and tests uses every core. If builds run one after another, 4 cores shorten the queue noticeably compared with two.
- Memory. A frontend build, Java or Go compilation and tests with a database in a container need 4–8 GB per job. The number of simultaneous jobs is set by the
concurrentparameter in GitLab Runner'sconfig.toml(parameter reference); multiply the memory of one job by that number. - Disk. Installing dependencies means thousands of small files, which is faster on NVMe. Take at least 50 GB and clean the Docker cache on a schedule with
docker builder pruneanddocker image prune. Why the disk matters is explained in the article NVMe or SSD: does it matter for a website. - Virtualization. The docker executor needs KVM: on OpenVZ, Docker works with limitations or not at all (KVM or OpenVZ: which to choose).
According to the accsly.io catalog as of November 2026, a VPS plan with 2 cores and 4 GB of memory costs a median of about $21 a month, and a quarter of such plans are cheaper than $12.5. With 4 cores and 8 GB the median is $40, with a quarter below $22. With 8 cores and 16 GB it is $80. The catalog keeps prices in roubles; dollar figures are converted at the catalog rate of 7 November 2026. Suitable plans are collected in VPS for CI/CD runners and, if you also host GitLab itself, VPS for GitLab.
A build server billed by the hour
A runner is needed only while builds are running, usually a few hours of a working day. According to the accsly.io catalog as of November 2026, 173 of 2,248 hosts with VPS plans offer hourly billing, 298 offer an API for creating and deleting servers, and 96 offer both. A plan with 4 cores and 8 GB at hosts with hourly billing costs a median of $51 a month, about $0.07 an hour. A server that runs 3 hours on each of 22 working days costs about $4.6 instead of the monthly price.
To make this work without manual steps, the server is created through the API before a build and deleted afterwards. GitLab Runner can autoscale this way with cloud plugins, and GitHub recommends ephemeral runners that take one job and are then removed. A bonus for security: the next job sees no traces of the previous one. When hourly billing beats monthly is explained in the article Hourly billing: when it pays off; plans are collected in VPS with hourly billing and VPS with an API.
Deploying over SSH with a restricted key
The simplest way to deliver a build to the production server is SSH: rsync the files or run a command that fetches the new version and restarts the service. To keep the CI key from becoming a key to the whole server:
- A separate user. Create a
deployuser without sudo and with rights only to the site's directory. If a service needs restarting, allow that one specific command in sudoers. - A separate key. Generate a key pair for CI only and do not use it anywhere else.
- Restrictions in authorized_keys. Put
restrictbefore the key, which disables port forwarding and the terminal, andfrom="runner address"so the key works only from the build server. Thecommand="…"option lets the key run only one deploy script (man authorized_keys). - Server verification. Store the production server's host key fingerprint in a CI variable and write it to
known_hostsbefore connecting. Never turn verification off withStrictHostKeyChecking=no: the runner would then connect to any server that pretends to be yours.
To keep the site running during a deployment, start the new version next to the old one and switch traffic once it passes a health check; the previous version stays in place for a rollback. How to set this up on a single server is covered in Updating a server without downtime.
Basic protection of the production server itself, with key-only login, a firewall and fail2ban, is described in the article First 10 steps after buying a VPS: security. Login records for the deploy key help you find out what happened if something goes wrong; where to find them is covered in the article Server logs and login auditing.
Where to keep secrets
- Not in the repository. Database passwords, API tokens and deploy keys are stored in CI variables. In GitLab a variable can be protected, so it is available only to pipelines on protected branches and tags, and masked, so its value is replaced with
[MASKED]in the job log. A masked value must be a single line without spaces and at least 8 characters long (GitLab documentation). - One value per secret. GitHub warns that a secret stored as JSON or YAML is harder to redact from logs; store each value separately and never print secrets to the build log.
- Different secrets for staging and production. The production deploy key should be available only to a protected branch, not to any developer's branch.
- Short-lived tokens instead of permanent ones. For cloud services GitHub recommends OpenID Connect: the job receives a token for the duration of the build, and you do not have to store a permanent key.
- Rotation. Change keys when someone with access to the CI settings leaves the team and after any leak of a build log.
The .env file with the production settings is created on the server once, with permissions 600, and the deployment never overwrites it. Then even a full copy of the repository contains no passwords to the production database.