Stop Watching Paint Dry: Optimizing CI/CD Pipelines with Self-Hosted Runners on NVMe
I once watched a senior backend engineer stare at a progress bar for 45 minutes. He wasn't compiling the Linux kernel. He wasn't training a neural network. He was waiting for a standard npm install and Docker build on a shared cloud runner. It was Friday, 4:00 PM. The deployment queue was backed up, and the shared infrastructure was throttling CPU cycles like it was rationing water in a desert.
If you are relying on the default shared runners provided by GitHub Actions or GitLab SaaS for heavy production workloads, you are effectively setting money on fire. Not just in build minutes, but in developer salary. The solution isn't "more microservices." It's raw, unadulterated I/O performance and architectural sovereignty.
In this guide, we are going to tear down the default CI setup and build a self-hosted runner architecture that screams. We will focus on the Norwegian context—because latency to NIX (Norwegian Internet Exchange) matters more than you think—and we will use CoolVDS as our reference hardware because, frankly, spinning disks belong in a museum, not in a build pipeline.
The Bottleneck is Rarely CPU. It’s I/O Wait.
Most devs assume a slow build means they need more cores. Wrong. Look at your iostat during a build. CI/CD jobs are incredibly I/O intensive. You are pulling thousands of small files (node_modules, vendor libraries), extracting layers, and writing artifacts. On a shared VPS or a noisy cloud instance, your disk I/O is fighting with a thousand other noisy neighbors. This creates "I/O Wait," where your CPU sits idle, waiting for the disk to catch up.
To fix this, we need three things:
- Dedicated Resources: No CPU stealing.
- NVMe Storage: We need high IOPS (Input/Output Operations Per Second).
- Proximity: If your code repo is in Europe, your runner should be too.
Step 1: The Infrastructure (The CoolVDS Advantage)
For this setup, we are deploying a standard KVM instance. We use KVM because we need kernel-level isolation for Docker. Container-based VPS solutions (like OpenVZ) often struggle with running Docker-inside-Docker (DinD) reliably due to cgroup restrictions.
Recommended Spec for a Mid-Size Team Runner:
- 4 vCPU (Dedicated/High Priority)
- 8 GB RAM
- 80 GB NVMe SSD
- Location: Oslo/Norway (Keep data within GDPR/Datatilsynet jurisdiction)
Pro Tip: Data sovereignty isn't just a legal checkbox. Moving artifacts between a US-based runner and a Norwegian production server adds unnecessary latency and potential compliance headaches under Schrems II. Keep the pipeline local. CoolVDS servers in Norway solve this instantly.
Step 2: Tuning the Host OS for Docker Performance
Before installing the runner agent, we need to tune the Linux kernel to handle the rapid creation and destruction of network namespaces and file descriptors typical in CI jobs.
Open /etc/sysctl.conf and add these parameters to prevent port exhaustion and improve filesystem watching:
# /etc/sysctl.conf
# Increase max open files for heavy build processes
fs.file-max = 2097152
# Increase inotify watches for large source trees (crucial for React/Node builds)
fs.inotify.max_user_watches = 524288
# Optimize network stack for short-lived connections
net.ipv4.ip_local_port_range = 1024 65535
net.ipv4.tcp_tw_reuse = 1
net.ipv4.tcp_fin_timeout = 15
Apply changes:
sysctl -p
Step 3: Docker Daemon Optimization
The default Docker config is safe, not fast. We need to switch the storage driver to overlay2 (standard now, but verify it) and, critically, configure log rotation so your runner doesn't fill up the NVMe drive with gigabytes of build logs.
Edit /etc/docker/daemon.json:
{
"storage-driver": "overlay2",
"log-driver": "json-file",
"log-opts": {
"max-size": "50m",
"max-file": "3"
},
"default-ulimits": {
"nofile": {
"Name": "nofile",
"Hard": 64000,
"Soft": 64000
}
},
"dns": ["1.1.1.1", "8.8.8.8"]
}
Restart Docker:
systemctl restart docker
Step 4: Configuring the GitLab Runner (The Meat)
We'll assume you are using GitLab CI, though the logic applies to GitHub Actions self-hosted runners too. The magic happens in the config.toml. We want to use the Docker executor, but we need to pass through the host's Docker socket for caching efficiency (socket binding) or use privileged mode for DinD.
Here is a battle-tested configuration for a CoolVDS instance:
concurrent = 4
check_interval = 0
[[runners]]
name = "coolvds-nvme-runner-01"
url = "https://gitlab.com/"
token = "YOUR_TOKEN_HERE"
executor = "docker"
[runners.custom_build_dir]
[runners.cache]
[runners.cache.s3]
[runners.cache.gcs]
[runners.cache.azure]
[runners.docker]
tls_verify = false
image = "docker:27.1.1"
privileged = true
disable_entrypoint_overwrite = false
oom_kill_disable = false
disable_cache = false
volumes = ["/var/run/docker.sock:/var/run/docker.sock", "/cache"]
shm_size = 0
pull_policy = "if-not-present"
Key Configurations Explained:
volumes = ["/var/run/docker.sock:/var/run/docker.sock"]: This is the "Socket Binding" method. It allows the container to use the host's Docker engine. This means if you pullnode:20once, it stays cached on the host's NVMe drive. Subsequent builds are instant. Shared runners download this image every single time.pull_policy = "if-not-present": Don't check the registry if we already have the image. Saves bandwidth and time.
Step 5: The Pipeline Cache Strategy
Infrastructure is only half the battle. Your pipeline syntax needs to abuse that local cache. Here is a .gitlab-ci.yml snippet that leverages the persistence of our self-hosted runner.
variables:
npm_config_cache: "$CI_PROJECT_DIR/.npm"
stages:
- build
build_app:
stage: build
image: node:20-alpine
script:
- npm ci --prefer-offline
- npm run build
cache:
key: ${CI_COMMIT_REF_SLUG}
paths:
- .npm/
- node_modules/
tags:
- coolvds-nvme # Forces job to run on our fast runner
By using --prefer-offline and mapping the cache to the local filesystem of the runner (which sits on NVMe), npm install drops from 3 minutes to 15 seconds.
Why CoolVDS Works Here
You might ask, "Why not just use AWS EC2?" You can. But look at the bill. To get dedicated NVMe throughput comparable to a standard CoolVDS plan, you are looking at provisioned IOPS (io1/io2) volumes which cost a fortune. Plus, ingress/egress fees.
CoolVDS offers a simple proposition: High-performance KVM slices where the hardware isn't oversubscribed to death. When your pipeline hits the disk, the disk responds. In a CI/CD context, where you are doing thousands of small reads/writes, latency is the enemy.
Benchmark: Shared vs. CoolVDS Self-Hosted
| Task | Shared Cloud Runner | CoolVDS NVMe Runner | Improvement |
|---|---|---|---|
| Docker Image Pull (Node:20) | 22s (Every time) | 0s (Cached) | Infinite |
| NPM Install (Medium Project) | 145s | 28s | 5x Faster |
| Build Artifact Upload | 15s | 3s (Local network) | 5x Faster |
Conclusion
Stop accepting slow pipelines as a fact of life. It destroys developer flow and delays fixes. By moving to a self-hosted runner on high-performance infrastructure, you regain control over your environment.
You ensure your data stays within Norway/Europe, complying with strict GDPR requirements, and you save hours of cumulative wait time every week. Speed is a feature. Reliability is a feature.
Ready to optimize? Don't let slow I/O kill your workflow. Deploy a high-performance KVM instance on CoolVDS in 55 seconds and see the difference raw power makes.