Why use a dedicated server for CI/CD?
Continuous integration and continuous delivery systems compile code, run tests, build container images, scan dependencies, and publish artifacts. These tasks often create short periods of very high CPU, memory, disk I/O, and network demand. Jenkins agents and GitLab Runners also execute code from many branches, so resource contention can make pipeline duration unpredictable.
A dedicated server gives the CI system exclusive access to its CPU cores, memory, NVMe storage, and network port. That isolation reduces the effect of neighboring workloads and makes build times easier to estimate. It also lets an administrator tune the operating system, Docker storage, caching, swap policy, and security controls for the runner workload.
VPS or dedicated server?
A VPS is enough when
- The team runs fewer than 5 concurrent jobs.
- Most builds finish in under 10 minutes and do not compile large projects.
- Container images and dependencies are small enough for 80 to 160 GB of NVMe storage.
- Build failures caused by CPU or disk contention have little business impact.
- The CI service is separate from production databases and customer-facing applications.
A 4 vCPU, 8 GB RAM VPS with 160 GB NVMe storage can support a small Jenkins controller, one lightweight agent, or a GitLab Runner for web applications and infrastructure repositories. Keep the controller and build jobs separate where possible; Jenkins controllers should schedule work rather than perform heavy compilation.
Move to bare metal when
- More than 10 jobs run at the same time or queue delays regularly exceed 5 minutes.
- Builds use all allocated vCPUs for sustained periods.
- Projects compile large Java, Rust, C++, Android, or .NET codebases.
- Docker image builds produce many gigabytes of temporary layers.
- Several teams need isolated runners or dedicated executors.
- Builds require predictable timing for releases, testing, or deployment windows.
- The system stores large artifact caches, package mirrors, or container registries.
Bare metal is also a good choice for privileged workloads. Docker-in-Docker, nested virtualization, hardware testing, and high-volume integration tests can create noisy-neighbor, security, or I/O concerns on a shared virtual host.
Recommended dedicated server specifications
For a general Jenkins or GitLab Runner host, start with 8 physical cores, 32 GB of ECC RAM, and 480 GB or 960 GB enterprise NVMe storage. Select a recent server CPU with strong single-thread performance as well as enough cores for parallel jobs. Compilation and test suites benefit from both characteristics.
- CPU: 8 to 16 physical cores for medium teams; 16 to 32 cores for large build farms. Avoid sizing only by thread count because simultaneous multithreading does not equal a second physical core.
- RAM: 32 GB for 10 to 20 concurrent jobs, 64 GB for 20 to 40 jobs, and 128 GB or more for large language builds, Android builds, browser testing, or many containers.
- Storage: NVMe SSD is preferred. Use 480 GB for a lean runner and 960 GB to 1.92 TB for local caches, artifacts, and container layers. RAID 1 protects availability; it does not replace off-server backups.
- Network: 1 Gbps is adequate for most teams. Choose 10 Gbps when runners pull large images, mirror packages, or transfer multi-gigabyte artifacts frequently. A monthly transfer allowance of 10 TB is a practical starting point.
- Operating system: Debian 12, Ubuntu Server 24.04 LTS, or another supported Linux distribution. Keep the host minimal and place application services on separate machines.
Do not place Jenkins, GitLab, a container registry, and a production database on one server just because the CPU has unused cores. CI jobs execute changing code and should have a clear trust boundary from source control and production systems.
For up to 10 concurrent jobs, a 4 vCPU VPS is usually enough; beyond 20 concurrent jobs or sustained compilation, use a dedicated 16-core server with 64 GB RAM and 960 GB NVMe.
| Concurrent CI jobs | vCPU or cores | RAM | Disk | Monthly bandwidth |
|---|---|---|---|---|
| Up to 5 | 4 vCPU | 8 GB | 160 GB NVMe | 2 TB |
| 6 to 20 | 8 physical cores | 32 GB | 480 GB NVMe | 5 TB |
| 21 to 50 | 16 physical cores | 64 GB | 960 GB NVMe, RAID 1 | 10 TB |
| 51 to 100 | 32 physical cores | 128 GB | 1.92 TB NVMe, RAID 1 | 20 TB |
Step-by-step setup
1. Prepare DNS, access, and a clean host
Create a dedicated DNS record such as ci.example.com and use SSH keys instead of password authentication. Provision a supported Linux image and create a non-root administrator. Restrict SSH with a firewall and allow access only from trusted office, VPN, or management addresses.
2. Update the operating system
Install security updates before adding Jenkins or GitLab Runner:
sudo apt update && sudo apt full-upgrade -y
Set the correct timezone and enable automatic security updates where your change policy permits. Reboot after kernel updates.
3. Configure the firewall
Expose only required ports. Jenkins commonly uses TCP 8080 behind a reverse proxy, while GitLab Runner normally makes outbound connections and does not need an inbound service port.
sudo apt install -y ufw sudo ufw default deny incoming sudo ufw default allow outgoing sudo ufw allow from 203.0.113.10 to any port 22 proto tcp sudo ufw allow 80/tcp sudo ufw allow 443/tcp sudo ufw enable
4. Install Docker for isolated jobs
Docker-based jobs provide repeatable dependencies and make cleanup easier. Install Docker from the distribution-supported repository or Docker's official repository, then verify the daemon:
sudo apt install -y docker.io sudo systemctl enable --now docker sudo docker run --rm hello-world
Do not give untrusted users unrestricted access to the Docker socket. Membership in the docker group is effectively root access on the host. Use trusted repositories, protected branches, isolated runners, or rootless alternatives for less trusted code.
5. Install and register GitLab Runner
Install the runner package using the vendor repository, then register it with a project or group token. Use tags such as docker, linux, or privileged only when the pipeline requires them.
curl -L https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.deb.sh | sudo bash sudo apt install -y gitlab-runner sudo gitlab-runner register
Select the Docker executor when prompted. Set a concurrency limit in /etc/gitlab-runner/config.toml so the host retains memory and CPU capacity for system tasks.
6. Install Jenkins on a separate controller or agent
For a small deployment, Jenkins can run on the same host as one agent. For larger installations, keep the controller on a small VPS and use the dedicated server as an inbound or outbound build agent. Install Java supported by the selected Jenkins release, then install Jenkins from its signed repository.
sudo apt install -y fontconfig openjdk-21-jre sudo systemctl enable --now jenkins sudo systemctl status jenkins
Put Jenkins behind an HTTPS reverse proxy, require authentication, disable anonymous builds, and restrict agent permissions. Prefer ephemeral agents or disposable containers for untrusted pull requests.
7. Add caching and artifact retention
Cache package dependencies and compiler outputs only when cache invalidation is reliable. Set retention rules for build logs and artifacts; keeping every artifact indefinitely will eventually fill the NVMe volume. Store release artifacts in object storage or a dedicated registry with lifecycle policies.
8. Test concurrency before production use
Run several representative pipelines at once and measure CPU utilization, memory pressure, disk latency, network transfer, and queue time:
sudo apt install -y sysstat iotop mpstat -P ALL 5 iostat -xz 5 free -h
Increase executor counts only after confirming that RAM and disk latency remain within acceptable limits.
Performance optimization
Match executors to physical resources
Too many executors create queue-free but slow pipelines. Start with one executor per physical core for CPU-heavy compilation, then test carefully. Browser tests, network tests, and I/O-heavy jobs may support more parallelism, while Android and large C++ builds may need several gigabytes of RAM per executor.
Use fast, controlled Docker storage
Keep Docker's data root on NVMe and monitor usage with docker system df. Clean unused layers on a schedule rather than during peak builds:
docker system df sudo docker image prune -af --filter until=168h
Use registry mirrors or GitLab dependency proxies where appropriate. Avoid deleting active volumes and never run broad cleanup commands without confirming retention requirements.
Protect memory and disk space
Set job-level memory limits for containers, reserve RAM for the operating system, and alert when the filesystem exceeds 80 percent usage. A small swap file can prevent an abrupt process kill, but swap is not a substitute for sufficient RAM:
sudo fallocate -l 8G /swapfile sudo chmod 600 /swapfile sudo mkswap /swapfile sudo swapon /swapfile
Reduce unnecessary pipeline work
- Use dependency lockfiles and pinned base images.
- Run unit tests on every commit and expensive integration tests on protected branches or scheduled builds.
- Use shallow Git clones when full history is unnecessary.
- Build once, then promote the tested artifact between environments.
- Parallelize independent stages while keeping concurrency below the server's tested capacity.
Monitor the CI host
Track queue duration, job duration, executor utilization, load average, memory pressure, disk latency, filesystem capacity, and failed jobs. Alert on sustained load above the available physical core count, disk usage above 80 percent, and repeated out-of-memory events. Centralize logs so a failed host does not erase the evidence needed for diagnosis.
Common pitfalls to avoid
- Running builds on the Jenkins controller: keep controller executors at zero or a low value and assign heavy work to agents.
- Overcommitting executors: 32 logical threads do not guarantee 32 fast compilation jobs. Benchmark with real repositories.
- Using one shared privileged runner: separate trusted release jobs from pull-request jobs and restrict privileged Docker access.
- Ignoring disk growth: container layers, workspace directories, test reports, and artifacts can fill a 480 GB disk quickly.
- Skipping backups: back up Jenkins configuration, credentials metadata, pipeline definitions, and runner configuration, while keeping secrets encrypted.
- Leaving management interfaces public: place Jenkins behind HTTPS, enforce MFA through the identity provider, and restrict administrative paths.
- Using floating dependencies: unpinned packages and base images make builds difficult to reproduce and can introduce supply-chain risk.
- Mixing CI with production: a compromised build job should not have direct network access to production databases or deployment credentials.
When to add more servers
A single large server is not always the best design. Add separate agents when projects need different operating systems, CPU architectures, GPU access, or trust levels. A controller plus two or more dedicated agents can reduce queue time and allow maintenance without stopping every pipeline. Use labels and runner tags to route Android, ARM, browser, release, and security-scanning jobs to suitable hardware.
For high availability, keep source control and CI metadata backed up independently, maintain a replacement runner image, and document registration and secret-rotation procedures. CI availability is measured not only by server uptime but also by the ability to reproduce a clean build after a failure.