Console Login

CI/CD Latency Kills Velocity: Optimizing Self-Hosted Runners for Speed and Sovereignty

CI/CD Latency Kills Velocity: Optimizing Self-Hosted Runners for Speed and Sovereignty

I recently watched a senior backend engineer stare at a spinning "Pending" icon for 14 minutes. He wasn't compiling the kernel. He was waiting for a shared runner on a major public cloud provider to pick up a simple linting job. That is 14 minutes of burned salary, broken context, and frustration. If you multiply that by 10 developers doing 5 pushes a day, you are hemorrhaging money.

We talk about "shifting left" and "DevSecOps," but we ignore the iron that runs these pipelines. In 2024, sticking with default, shared SaaS runners for heavy lifting is a strategic error. They are oversold, I/O throttled, and often located halfway across the continent from your deployment target.

This isn't just about impatience. It's about TCO and, for us operating in Europe, data sovereignty. When your CI pipeline processes database dumps for integration testing, does that data leave the EEA? If you are using default runners, it might. Let's fix this using raw compute power, optimized configurations, and local infrastructure.

The I/O Bottleneck: Why Your npm install is Slow

Most CI/CD jobs are I/O bound, not CPU bound. Unzipping artifacts, pulling Docker images, and hydration of node_modules or vendor directories hammer the disk. Shared cloud instances usually cap IOPS. When a noisy neighbor on the same hypervisor decides to re-index their Elasticsearch cluster, your build times spike.

To solve this, you need dedicated I/O throughput. This is why we default to NVMe storage at CoolVDS. The random read/write speeds of NVMe versus standard SSDs can reduce dependency installation time by 40-60%. But hardware is only half the battle. You need to tune the OS and the runner.

1. Tweak the File System handles

Heavy CI jobs open thousands of files. The default Linux limits are often too low.

sysctl -w fs.inotify.max_user_watches=524288

Add this to your /etc/sysctl.conf to make it permanent. If you hit the file descriptor limit during a parallel test execution, your pipeline fails silently or hangs.

2. Docker Layer Caching Strategy

If you are building Docker images inside your CI, you must leverage layer caching. However, standard Docker-in-Docker (dind) setups often start with a cold cache every time. We need to mount the overlay directory or use a registry mirror.

Here is a robust .gitlab-ci.yml pattern utilizing a pre-pulled cache image to speed up builds:

stages:
  - build

variables:
  DOCKER_DRIVER: overlay2
  # Use a local registry mirror if you host one in your CoolVDS private network
  DOCKER_OPTS: "--registry-mirror=http://10.10.0.5:5000"

build_image:
  stage: build
  image: docker:27.1.1
  services:
    - name: docker:27.1.1-dind
      command: ["--mtu=1400"]
  script:
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
    - docker pull $CI_REGISTRY_IMAGE:latest || true
    - |
      docker build \
        --cache-from $CI_REGISTRY_IMAGE:latest \
        --tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA \
        --tag $CI_REGISTRY_IMAGE:latest .
    - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
    - docker push $CI_REGISTRY_IMAGE:latest

Note the --mtu=1400 flag. If you are tunneling your CI traffic through a VPN or overlay network (common when connecting to secure Norwegian corporate networks), fragmentation issues can cause hanging pulls. Lowering the MTU prevents this.

Distributed Caching with MinIO

Local caching on a shell runner is fast, but it doesn't scale if you use ephemeral runners (which you should, for security). The solution is a shared object storage cache. Instead of paying egress fees to AWS S3, host a MinIO instance on a separate CoolVDS VPS within the same internal LAN.

Latency matters here. If your runner is in Oslo and your cache is in Frankfurt, the round-trip time (RTT) kills the benefit of caching. Keep them in the same datacenter.

Configuring the GitLab Runner config.toml for S3-compatible caching:

[[runners]]
  name = "coolvds-norway-runner-01"
  url = "https://gitlab.com/"
  token = "REDACTED"
  executor = "docker"
  [runners.cache]
    Type = "s3"
    Path = "gitlab-runner"
    Shared = true
    [runners.cache.s3]
      ServerAddress = "s3.coolvds.internal:9000"
      AccessKey = "ACCESS_KEY"
      SecretKey = "SECRET_KEY"
      BucketName = "runner-cache"
      Insecure = false
Pro Tip: When using internal networks (LAN) between your runner and your cache server on CoolVDS, you avoid public bandwidth costs and achieve sub-millisecond latency. This allows you to cache massive node_modules or .m2 folders without penalty.

Infrastructure as Code for Runners

Never configure runners manually. You want them to be disposable. If a build script goes rogue and pollutes the environment, you should be able to kill the runner and spawn a fresh one instantly. Here is how we define a runner using Terraform (compatible with OpenTofu) on a KVM-based provider:

resource "coolvds_instance" "ci_runner" {
  count     = 3
  name      = "ci-runner-osl-${count.index}"
  region    = "oslo"
  image     = "ubuntu-24.04"
  plan      = "nvme-8gb" # High memory for Java/compilation tasks
  user_data = templatefile("${path.module}/cloud-init.yaml", {
    gitlab_token = var.gitlab_runner_token
  })

  network_interface {
    subnet_id = coolvds_subnet.private_devops.id
    nat       = true
  }

  tags = ["devops", "ci", "ephemeral"]
}

Using user_data allows the instance to register itself upon boot and deregister upon shutdown. This elasticity is crucial for cost management.

The Norwegian Context: Latency and Legality

Why host this in Norway? Two reasons: NIX (Norwegian Internet Exchange) and GDPR.

If your developers are in Oslo or Bergen, pushing code to a server in US-East introduces unnecessary latency. Every git push, every log stream, every artifact download travels across the Atlantic. By hosting runners on CoolVDS in Oslo, you are often 1-3 hops away from your developer's ISP via NIX.

More importantly: Data Residency. In 2024, the scrutiny from Datatilsynet regarding data transfers is high. If your CI/CD pipeline runs integration tests using a sanitized copy of production data, that data is being processed by the runner. If that runner is a shared instance owned by a US hyper-scaler, you are navigating a legal minefield regarding Schrems II. A self-hosted runner on Norwegian soil, controlled entirely by you, simplifies your compliance posture significantly.

Monitoring the Pipeline

You can't optimize what you don't measure. Don't just rely on the CI platform's UI. Run node_exporter on your runners to catch CPU steal or I/O waits.

sudo apt-get install prometheus-node-exporter

Check for "CPU Steal" specifically. This metric tells you if your host is overselling CPU cycles.

curl localhost:9100/metrics | grep node_cpu_seconds_total | grep steal

If this number is climbing rapidly, your provider is the bottleneck. We configure CoolVDS KVM slices with strict resource guarantees to ensure that when your compiler asks for 100% CPU, it gets it immediately, not after the hypervisor schedules it in.

Conclusion

Optimizing CI/CD is about removing friction. The friction of disk I/O, the friction of network latency, and the friction of unreliable shared resources. By moving to self-hosted runners on high-performance infrastructure like CoolVDS, you regain control.

You get the raw speed of local NVMe storage, the legal safety of Norwegian data residency, and the flexibility to cache aggressively without breaking the bank. Your developers shouldn't have time to get coffee during a build.

Don't let slow I/O kill your velocity. Deploy a dedicated runner on a CoolVDS NVMe instance in Oslo today and cut your build times in half.