A self-managed GitLab Runner moves CI/CD work onto a VPS you control. It is not the GitLab application itself: the runner polls GitLab over outbound HTTPS, starts job containers with the Docker executor, uploads the result, and waits for more work. That design means a basic runner needs no public application port.
This guide targets Ubuntu 24.04 LTS on x86-64, the official GitLab Runner apt repository, Docker Engine, and one non-privileged Docker job at a time. GitLab explicitly recommends installing a runner separately from the GitLab server. Treat it as a remote-code-execution host, because every accepted pipeline is code running on this VPS.
GitLab Runner VPS requirements and LayerOne cost
| Item | Practical starting point |
|---|---|
| Minimum RAM | 2 GB for one small lint or test job; language toolchains may need much more |
| Recommended RAM | 4 GB for one general-purpose Docker job with host headroom |
| Expected CPU usage | Runner manager is usually below 1–2% of one vCPU while idle; a compile, test, compression, or image build can sustain 100% of every vCPU assigned |
| Storage requirement | 40 GB is a small floor; use 60 GB or more for the OS, job workspaces, images, layers, and cache, then monitor actual churn |
| Exact LayerOne SKU | gc.small (layerone-4g): 2 vCPU, 4 GB RAM, 60 GB disk |
| Expected monthly cost | $0.0109/hour, about $8.00 for 730 hours at the catalog's monthly equivalent |
CI sizing is determined by the job, not by gitlab-runner itself. A Java build,
browser test, Android build, container image build, or security scanner can
consume many times the resources of a small Python lint job. Start with
concurrent = 1, record representative peak CPU, RAM, and disk use, then
resize the VPS or add isolated runners.
LayerOne bills the VPS hourly from prepaid credit. The monthly equivalent above does not include taxes, optional services, external registry or GitLab charges, or transfer outside the account allowance. Check Pricing for the current catalog.
1. Harden the host and install Docker
Complete Your first hour on a new server, then install Docker Engine and the Compose plugin using Docker on a VPS. Keep the host dedicated to CI; do not place a production database, application secrets, or a control panel on the same server.
Verify Docker before adding the runner:
sudo docker version
sudo docker run --rm hello-world
2. Install GitLab Runner from GitLab's repository
GitLab's official repository setup script is downloadable so you can inspect it before running it:
sudo apt update
sudo apt install -y ca-certificates curl
curl -L "https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.deb.sh" -o /tmp/gitlab-runner-repository.sh
less /tmp/gitlab-runner-repository.sh
sudo bash /tmp/gitlab-runner-repository.sh
sudo apt install -y gitlab-runner
gitlab-runner --version
sudo systemctl status gitlab-runner --no-pager
The Docker executor needs permission to reach the local Docker daemon. The
docker group is effectively root-equivalent on this host, which is one reason
the runner must be isolated:
sudo usermod -aG docker gitlab-runner
sudo systemctl restart gitlab-runner
sudo -u gitlab-runner -H docker version
Never expose Docker's unauthenticated TCP socket on ports 2375 or 2376.
3. Register one scoped runner without leaking its token
In GitLab, create a project runner or narrowly scoped group runner. Copy the
runner authentication token, whose current prefix is glrt-. Run interactive
registration so the token does not land in shell history:
sudo gitlab-runner register
Use these answers:
| Prompt | Value |
|---|---|
| GitLab instance URL | https://gitlab.com/ or the root URL of your self-managed GitLab instance |
| Authentication token | The glrt-... token from the runner setup page |
| Description | layerone-docker-runner |
| Tags | layerone (or a workload-specific tag) |
| Executor | docker |
| Default Docker image | ubuntu:24.04 |
Then verify both the local service and its GitLab connection:
sudo gitlab-runner list
sudo gitlab-runner verify
sudo gitlab-runner status
sudo journalctl -u gitlab-runner -n 100 --no-pager
verify proves that the registered manager can contact GitLab; a smoke pipeline
proves that the executor can actually start a job.
4. Keep concurrency inside the VPS capacity
Open /etc/gitlab-runner/config.toml and keep the global setting at one job
while establishing a baseline:
concurrent = 1
Do not enable privileged = true merely because a build asks for it. GitLab
warns that privileged containers can escape the expected isolation and gain
root-level control of the host. If a trusted protected pipeline truly needs
Docker-in-Docker, put it on a separate, replaceable runner and follow GitLab's
rootless/restricted privileged guidance.
Validate configuration before restarting:
sudo gitlab-runner lint --config /etc/gitlab-runner/config.toml
sudo systemctl restart gitlab-runner
5. Use an outbound-only firewall posture
The runner initiates its connection to GitLab and registries. No inbound CI service port is required. Replace the example administrator address:
ADMIN_CIDR="198.51.100.24/32"
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow from "$ADMIN_CIDR" to any port 22 proto tcp
sudo ufw enable
sudo ufw status verbose
Set the LayerOne cloud firewall inbound policy to DROP, allow TCP 22 only from
the administrator CIDR, and add no public web, Docker, metrics, cache, or debug
ports. The default outbound policy can remain ACCEPT; a runner normally needs
DNS plus HTTPS access to GitLab, container registries, package repositories,
and the destinations its approved jobs use.
| Port | Direction | Policy |
|---|---|---|
| TCP 22 | Inbound | Administrator CIDR only; remove when administration is entirely over a private access path |
| TCP 443 | Outbound | GitLab API/long polling, registries, packages, and artifacts |
| UDP/TCP 53 | Outbound | DNS resolution when using conventional resolvers |
| TCP 2375/2376 | Inbound | Never expose the Docker API |
| Application test ports | Inbound | Do not publish; keep job services on Docker's internal network |
Docker-published ports can bypass UFW. The upstream LayerOne firewall still
blocks them when inbound is DROP, but jobs should not publish host ports at
all unless the pipeline explicitly needs them.
6. Run a smoke pipeline
Add a temporary job to a project that is allowed to use the runner:
runner-smoke:
image: ubuntu:24.04
tags:
- layerone
script:
- nproc
- head -n 3 /proc/meminfo
- df -h /
- echo runner-ok
The job should be picked up by layerone-docker-runner and finish with
runner-ok. During the job, inspect the host:
sudo docker stats --no-stream
free -h
df -h /
If the job is stuck, confirm its tag matches, the runner is not paused, and
gitlab-runner verify reports it alive.
7. Control disk growth
Images, stopped containers, build directories, and caches can fill a small CI disk quickly:
sudo docker system df
sudo du -sh /var/lib/docker /home/gitlab-runner 2>/dev/null
df -h /
Delete only resources you have confirmed are unused. For example, this removes unused images older than seven days but does not delete named volumes:
sudo docker image prune -af --filter "until=168h"
Do not make docker system prune --volumes a blind cron job. Build caches are
disposable only if the pipeline can recreate them; artifacts that matter should
be uploaded to GitLab or an artifact store rather than living only on the runner.
8. Back up, restore, replace, and upgrade deliberately
Update the runner from the official repository during a quiet window:
sudo apt update
sudo apt install --only-upgrade gitlab-runner
sudo gitlab-runner verify
sudo systemctl status gitlab-runner --no-pager
Runner hosts should be replaceable. A runner backup normally needs only
/etc/gitlab-runner/config.toml and any deliberately retained cache; build
artifacts and source should already exist in GitLab or an artifact store. The
configuration contains a runner authentication token, so encrypt the backup and
restrict access.
For a clean restore, install a fresh host and register it again instead of
restoring Docker's entire data directory. If restoring config.toml, keep the
old manager offline, run gitlab-runner verify, and remove the old manager from
GitLab after replacement so a stolen or duplicate token cannot reconnect.
Security checklist
- Scope the runner to the smallest project or group that needs it.
- Use protected runners/tags for deployment jobs and protected secrets.
- Never run unreviewed fork pipelines on a runner that can reach production.
- Leave Docker executor
privilegedoff; do not mount/var/run/docker.sockinto job containers. - Keep long-lived cloud credentials and SSH keys off the host filesystem.
- Put runners in a separate network segment; do not attach them to a trusted private network without explicit need.
- Keep one job at a time until measurements justify more concurrency.
- Monitor free disk, OOM kills, job queue time, and runner version.