Back to articles
Technology Insight

Building Your Own CI/CD System: A Practical Guide to Replacing GitHub Actions with VPS, GitLab Runner, and Docker

May 17, 2026

Introduction: The Case for Self-Hosted CI/CD

Continuous Integration and Continuous Deployment (CI/CD) have become fundamental pillars of modern software development. While cloud-based solutions like GitHub Actions offer convenience, they come with limitations in cost, control, and customization. For teams seeking greater autonomy, performance, and long-term cost efficiency, building a self-hosted CI/CD system presents a compelling alternative.

This guide explores a practical architecture using a Virtual Private Server (VPS), GitLab Runner, and Docker containers. This combination provides a robust, scalable foundation that you fully control, eliminating dependency on third-party platforms and their associated usage limits.

Architectural Overview: Core Components

Our proposed system rests on three primary components, each serving a distinct purpose in the pipeline.

1. Virtual Private Server (VPS)

The VPS acts as the physical (or virtual) host for our entire CI/CD environment. It provides the compute resources, storage, and network connectivity. Key advantages include:

  • Predictable Costing: Fixed monthly fees, unlike the variable, usage-based pricing of many cloud CI services.
  • Full Root Access: Complete control over the operating system, installed software, and security configurations.
  • Resource Isolation: Dedicated CPU, RAM, and disk I/O ensure consistent performance unaffected by "noisy neighbors."
  • Geographic Flexibility: Choose a data center location that minimizes latency for your team and your deployment targets.

2. GitLab Runner

GitLab Runner is the open-source application that processes your CI/CD jobs. It connects to your GitLab repository (or GitHub, using its API) and executes the instructions defined in your .gitlab-ci.yml file. It is highly configurable and supports several executors, with Docker being the most powerful for creating isolated, reproducible build environments.

3. Docker

Docker provides the containerization layer. Each CI job runs inside a fresh, ephemeral container defined by a Dockerfile or a pre-built image. This ensures:

  • Environment Consistency: The build environment is identical every time, eliminating "it works on my machine" problems.
  • Isolation and Security: Jobs are sandboxed from the host system and from each other.
  • Rapid Setup: Dependencies are pre-packaged in images, drastically reducing job startup time compared to provisioning a full VM.

Step-by-Step Implementation Guide

Phase 1: VPS Provisioning and Setup

Begin by selecting a VPS provider (e.g., DigitalOcean, Linode, AWS EC2, or a local host). A machine with 2-4 vCPUs, 4-8 GB RAM, and 50-100 GB SSD storage is a good starting point for small to medium teams. Install a stable Linux distribution like Ubuntu 22.04 LTS.

Essential initial configuration includes:

  1. Creating a non-root user with sudo privileges.
  2. Configuring a firewall (UFW) to allow only SSH, HTTP, and HTTPS traffic initially.
  3. Installing Docker Engine and Docker Compose following the official documentation.
  4. Setting up automated security updates.

Phase 2: Installing and Registering GitLab Runner

Install the GitLab Runner binary on your VPS. For a Debian/Ubuntu system, you can use the official repository:

curl -L "https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.deb.sh" | sudo bash
sudo apt-get install gitlab-runner

The critical step is registering the runner with your Git repository. For a GitLab.com or self-hosted GitLab instance, navigate to your project's Settings > CI/CD > Runners to find the registration token. Then run:

sudo gitlab-runner register

During registration, you will specify the coordinator URL (e.g., https://gitlab.com), the registration token, a description for the runner, and the tags (like docker, linux). Most importantly, set the executor to docker and specify a default Docker image (e.g., docker:24).

Phase 3: Configuring the Docker Executor

The runner's configuration file (typically /etc/gitlab-runner/config.toml) is the control center. Key settings for the Docker executor include:

  • volumes: Mount directories for caching (e.g., ["/cache"]) and sharing the Docker socket (["/var/run/docker.sock:/var/run/docker.sock"]) for Docker-in-Docker (dind) scenarios.
  • pull_policy: Set to "if-not-present" to speed up jobs by using locally cached images.
  • allowed_images: Restrict which Docker images can be used for security.

A sample configuration for a runner that can build Docker images might look like this:

[[runners]]
name = "My Docker Runner"
url = "https://gitlab.com"
executor = "docker"
[runners.docker]
tls_verify = false
image = "docker:24"
privileged = true
volumes = ["/var/run/docker.sock:/var/run/docker.sock", "/cache"]

Phase 4: Crafting Your .gitlab-ci.yml Pipeline

With the infrastructure ready, you define your workflow in the project's .gitlab-ci.yml file. This is where you translate GitHub Actions workflows. A pipeline typically has stages like test, build, and deploy.

Example pipeline for a Node.js application:

stages:
- test
- build
- deploy

cache:
paths:
- node_modules/

test:
stage: test
image: node:20-alpine
script:
- npm ci
- npm run test:unit
- npm run test:integration

build:
stage: build
image: docker:24
services:
- docker:24-dind
script:
- docker build -t my-app:$CI_COMMIT_SHORT_SHA .
- docker tag my-app:$CI_COMMIT_SHORT_SHA my-registry.com/my-app:latest
- docker push my-registry.com/my-app:latest

deploy:
stage: deploy
image: alpine:latest
script:
- apk add --no-cache openssh-client
- echo "$SSH_PRIVATE_KEY" > deploy_key
- chmod 600 deploy_key
- ssh -i deploy_key -o StrictHostKeyChecking=no user@production-server "docker pull my-registry.com/my-app:latest && docker-compose up -d"

Advanced Configuration and Optimization

Managing Secrets Securely

Never hardcode secrets in your .gitlab-ci.yml file. Use your GitLab project's Settings > CI/CD > Variables to store sensitive data like API tokens, Docker registry passwords, and SSH private keys. These are injected as environment variables into the job environment and are never logged.

Implementing Caching for Speed

Caching dependencies between pipeline runs is crucial for performance. Use the cache keyword in your YAML to preserve directories like node_modules/, vendor/, or target/. The GitLab Runner's Docker executor can persist these caches on the VPS host using the mounted volume.

Scaling with Multiple Runners and Tags

As your project grows, you can scale horizontally. Register additional runners on the same or different VPS instances. Use tags to direct specific jobs to specific runners. For example, tag one runner with docker, heavy for large build jobs and another with light, deploy for lightweight deployment tasks.

Comparative Analysis: Self-Hosted vs. GitHub Actions

Control and Customization: A self-hosted system offers unparalleled control. You choose the OS, runtime versions, and can install any specialized tooling directly on the host. GitHub Actions provides a curated, but limited, set of environments.

Cost Structure: For high-usage scenarios, a mid-tier VPS ($20-$50/month) can be significantly cheaper than the compute minutes consumed by a busy team on GitHub Actions. The break-even point depends entirely on your workload volume.

Performance and Latency: A self-hosted runner on a well-provisioned VPS often provides more consistent and faster job execution, especially for I/O-intensive tasks, as resources are not shared with thousands of other users.

Maintenance Overhead: This is the primary trade-off. You are responsible for runner updates, host OS security patches, monitoring, and troubleshooting infrastructure issues. GitHub Actions abstracts all this away.

Vendor Lock-in: Your CI/CD logic, defined in a standard .gitlab-ci.yml file, remains portable. The underlying runner infrastructure is commodity hardware. This reduces lock-in compared to proprietary workflow syntax.

Security Best Practices

Running your own CI/CD system elevates your security responsibilities. Adhere to these principles:

  • Principle of Least Privilege: Run the GitLab Runner service under a dedicated, non-root user account.
  • Regular Updates: Establish a routine to update the host OS, Docker, and the GitLab Runner binary.
  • Network Security: Use a firewall to restrict inbound access to the VPS. Consider placing runners in a private network if deploying to internal infrastructure.
  • Container Security: Use trusted, minimal base images. Scan images for vulnerabilities regularly. Avoid using privileged: true unless absolutely required for Docker-in-Docker.
  • Secret Management: As emphasized, rely exclusively on CI/CD variables for secrets; never log them.

Conclusion: Embracing Infrastructure as Code

Building your own CI/CD pipeline with a VPS, GitLab Runner, and Docker is more than a cost-saving exercise. It is an investment in capability and resilience. It deepens your team's understanding of the infrastructure that underpins your development lifecycle and grants the flexibility to adapt it to your unique needs.

The initial setup requires effort, but the long-term benefits of control, predictable performance, and reduced operational costs are substantial. By treating your CI/CD runner configuration as code—using tools like Ansible, Terraform, or even a Docker Compose file to provision it—you can recreate, scale, and version-control your entire pipeline infrastructure. This approach ultimately leads to a more mature, robust, and self-sufficient software delivery practice.