Building a CI/CD Runner

Aravind M SAravind M S
11 min read

Introduction

I recently came across something called Webhook. A webhook is a way for a system to send real-time data to another system automatically based on an event’s occurrence.

Let’s say we use a payment service like Stripe, and we want to be notified if a payment is successful or not.

  • We provide our server’s URL to Stripe as a webhook endpoint.

  • When the payment process is complete, Stripe sends us an HTTP POST request with, let’s say, the payment details.

So, a webhook is just a POST request? not that straightforward

An API follows what is called a “Pull” model, where the system asks for what it needs. Client → makes a request → Server → responds, whereas a webhook follows a “Push” model, where the other system notifies us when something happens. Server → sends a message → Our system (listener)

Both these patterns have their pros and cons associated with them, and it is our responsibility to better understand our use case and decide which pattern to deploy.

Why webhooks here? Is it not a CI/CD Runner blog? You’re right. I will get to that. This project was a result of my wanting to build something using webhooks, which is what CI/CD tools like GitHub Actions or Jenkins use to know when something has happened to our GitHub repository (commit/push, etc). And then they trigger the CI and CD operations (building, testing, deploying, etc). So I thought of building a mini GitHub Actions/Jenkins of my own. And that’s the intuition and lecture about webhooks.

Understanding the problem

The problem in my hand was to build a CI/CD Runner from scratch. So I had to first break down the components I need to build. There should be someone who listens to the GitHub repository where all the actions happen. Since the webhook is an HTTP POST request, we want an HTTP Server running to do this - a WebHook Listener.

The role of the webhook listener is to listen for requests from a GitHub Repository containing the repository details. This service must validate the request and dispatch the required data object to another service - the Job Orchestrator.

The role of the job orchestrator is to use the repo details, clone the repo, and locate the .runnerci.yml file (our custom CI Runner file) and parse the instructions to a specific structure - Job, Image, and Steps. Then the parsed YAML file object is dispatched to a Sandbox Executor.

The sandbox executor service handles the part where we have to run the Job (Set of steps) in an isolated environment, for eg, installing modules, running a file, etc. The service must spin up an isolated Docker container, run the steps, and validate it.

For simplicity's sake, the job orchestrator and sandbox executor service can be coupled to a single one, as there are many common areas in these services, like accessing terminal commands, cloning the repo, accessing Docker, etc.

But I chose to have two different services. These make up the core of our CI/CD Runner “Argon” (Yeah, that’s the name I decided, and I will refer to it this way for the rest of the blog).

Two other microservices are good to have, considering we have so many things going around - a Notification service and a Logger. They do what their name suggests logger service listens to all the core services for logs and persists them to MongoDB, and the notification service listens to the sandbox executor and sends the result to the email of the user using AWS SES (Amazon Simple Email Service).

Here’s a little overview

Let’s proceed with building them!

Building the WebHook Listener

The core functionalities the webhook listener has to perform are:

  • Exposes endpoint to receive GitHub webhook events.

  • Verifies signature and parses payload.

  • Sends job info to the message queue.

I have chosen to go with SpringBoot for this microservice because I like to do backend with Java 😭🤞. It’s a personal preference; you can choose the framework of your choice. The core modules of this service are webhook components and message queue components. An endpoint exposed to receive GitHub webhook events, validating it, parsing it to a payload, and publishing it to a message queue - “webhook.queue”. I’ve chosen RabbitMQ to be the designated messaging queue. You can try other options like Kafka and compare what works well.

Yes, I’ve hosted the webhook listener using ngrok because :

  • We need a hosted endpoint so that GitHub can access it.

  • This project’s main aim was to design decoupled systems, not to host it as a product (for now 🤫).

  • My other services would be running locally, so I didn’t want to overcomplicate during the development phase.

The payload sent is too long to add as an image here, so I will add the processed payload, which will be sent to the Job Orchestrator

{
  "repositoryUrl": "https://github.com/avd1729/CI-CD-Test.git",
  "repoName": "CI-CD-Test",
  "branch": "main",
  "commitId": "35ad107c949533f9191cdebc71d3d0d3b1eb7c5a",
  "commitMessage": "Update README.md",
  "pusherName": "avd1729",
  "pusherEmail": "94891044+avd1729@users.noreply.github.com",
  "timestamp": "2025-07-26T12:04:09.187547800Z",
  "triggerSource": "push",
  "projectType": "maven"
}

I’ve created a separate repository for triggering the CI/CD Runner. avd1729/Argon-CI-Test

That’s it for our webhook listener. We will get back to it in further sections.

Building the Job Orchestrator

Job Orchestrator’s main role is to listen to the webhook.queue, use the repository details to clone it, and parse the .runnerci.yml file to a set of executable instructions and pass it to the Sandbox Executor.

Sample .runnerci.yml

version: 1.0

jobs:
  build:
    image: python:3.10
    steps:
      - name: Install dependencies
        run: pip install -r requirements.txt

      - name: Run main script
        run: python main.py

The service parses the yml file into a set of instructions like

type Pipeline struct {
    Stages []Stage
}
type Stage struct {
    Name  string
    Steps []Step
}
type Step struct {
    Run string
}

The Job Orchestrator

  • Listens to the webhook. queue.

  • Fetches .runnerci.yml and repo code.

  • Breaks down the pipeline into discrete tasks.

  • Dispatches jobs to the executor service (publishes to sandbox.queue).

  • Tracks job status.

As I said earlier that the Job Orchestrator and Sandbox Executor can be combined into a single one, so it may seem this service doesn’t do much. which is true, but who is to judge 🌚.

I built both of these services using GoLang because I felt these were the performance-critical parts of the product, and Go being the language used to build Docker, it wasn’t a hard choice. And I like to code in languages I’m not strong in so that I learn something new.

Building the Sandbox Executor

We have come to the core part of Argon - The Sandbox executor. This service is responsible for running the steps sent from the orchestrator in an isolated environment (the steps can be malicious, so being detached or isolated is important).

The Sandbox Executor

  • Receives jobs (e.g., "run tests", "build project").

  • Spins up Docker containers for execution.

  • Applies resource limits and monitors output.

  • Sends logs/status to Logger & Notification service.

This was the part that provided me with the most learning while building it. Initially, I didn’t have any clue how my service would spin up a Docker container out of thin air. Is it possible?

Yes, it is. I used Go to interact with Docker on my machine natively.

  • Step 0: Cloning the repo.

  • Step 1: Pulling the image.

  • Step 2: Creating the container.

  • Step 3: Copying the repo into the container.

  • Step 4: Starting the container.

  • Step 5: Executing the steps.

Jobs are executed in Docker containers (isolated sandbox). Each step’s stdout and stderr are captured, Logs are streamed to log.queue in real-time.

{
  "level": "INFO",
  "message": "Starting job execution",
  "context": {
    "job": "python:3.10"
  }
}

{
  "level": "INFO",
  "message": "Cloning repository",
  "context": {
    "repo": "https://github.com/avd1729/CI-CD-Test.git",
    "step": "clone"
  }
}

{
  "level": "INFO",
  "message": "Pulling Docker image",
  "context": {
    "image": "python:3.10",
    "step": "pull"
  }
}

{
  "level": "INFO",
  "message": "Creating Docker container",
  "context": {
    "container": "sandbox_20vosmrp",
    "image": "python:3.10",
    "step": "create_container"
  }
}

{
  "level": "INFO",
  "message": "Copying repo into container",
  "context": {
    "container": "sandbox_20vosmrp",
    "repoPath": "/tmp/20vosmrp",
    "step": "copy_repo"
  }
}

{
  "level": "INFO",
  "message": "Executing step: Install dependencies",
  "context": {
    "container": "sandbox_20vosmrp",
    "step": "Install dependencies"
  }
}

{
  "level": "INFO",
  "message": "Executing step: Run main script",
  "context": {
    "container": "sandbox_20vosmrp",
    "step": "Run main script"
  }
}

{
  "level": "INFO",
  "message": "Job completed successfully",
  "context": {
    "container": "sandbox_20vosmrp",
    "job": "python:3.10"
  }
}

Building the Notification Service

We have covered the core parts of Argon by now. The next step is to build a notification service that notifies the developer of the results of running the CI script.

The major business decision to be taken was through which medium the developer will be notified

  • Email?

  • Whatsapp?

  • Slack?

  • Discord?

  • Dreams! 😭

For now, I choose to stick with Email. I would love to extend to other modes.

After choosing the medium, the next step was finding how to do it, when I checked for the viable options

  • SendGrid

  • MailGun

  • Amazon SES

  • Me writing mails under the hood 🫡

I choose to use SES because I’ve been exploring AWS for a good amount of time now, so integrating an AWS service is not an overhead for me. It also provides more control to me than other 3rd party services.

Cutting to the chase, the Notification Service

  • Listens to the “notification.queue”.

  • Sends job results to Email.

  • Can be integrated into a dashboard in the future.

Building the Logger

Initially, the logger may seem like that’s too much work to do, but that’s what helped me to share these logs with you; otherwise, I would have to run 5 services locally to just document things. And if something breaks, we would not know what was the reason. Logging is a good practice in general. Decoupling it is an even better practice. Having a global logger whose existence the individual services don’t know is the icing on the cake.

The core services log to “log.queue” as they carry on with their daily work, and the logger listens to this queue and persists them to MongoDB. The best characteristic I observed of this pattern is that the microservices are decoupled; they don’t know the existence of other services.

Even if the logger is currently down, it can process the logs from the queue when it comes back up. If we had had a REST or gRPC approach, the logs would be lost if the logger were down.

We can’t afford the other services to be down tho 😭. So adding a load balancer and spinning up multiple instances of the core services is a good way to handle things. You can check this to build a DIY Load balancer avd1729/Load-Balancer.

The Logger

  • Collects stdout/stderr from executor jobs.

  • Streams to the dashboard (via WebSocket or polling) - In the future.

  • Stores historical logs in a DB.

Workflow Overview

Here’s a visual flowchart of the project’s workflow

  1. Push Event on GitHub

  2. Webhook received at /webhook endpoint

  3. Webhook Service validates & places message in queue

  4. Job Orchestrator reads the queue, pulls code, and reads .runnerci.yml

  5. Tasks dispatched to Executor

  6. Executor runs each stage inside a sandbox (e.g., npm test, mvn clean install)

  7. Logs and status sent to Logger and Notification Service

  8. Email/dashboard updates notify devs

Connecting the dots

We have built Argon now, building it was not as simple as this blog may suggest, running 3-4 microservices at the same time with browser tabs open, ngrok running and stuff was miserable.

Triggering the webhook and checking if the Webhook Listener receives it, parses it correctly, and passes it to the message queue. Job Orchestrator listening to it, cloning the repo, and parsing it into executable steps. Sandbox listening to it, executing it in a sandboxed environment.

Notification service listening to it and sending an email of the result. Logger sitting all through, eating logs from the log queue; all of these steps must run in a smooth and synchronized manner. This may seem straightforward, but it can have a whole new level of things failing. But the learning prospect - nothing beats it! 😭🤌

System Intuition & Design Philosophy

This project was born from the idea of demystifying how CI/CD systems work and building a production-mimicking runner system using:

  • Polyglot microservices — right tool for the right job (Java for REST, Go for performance, Python for scripting)

  • Event-driven design — using RabbitMQ as the backbone for inter-service communication

  • Separation of concerns — listener, orchestrator, executor, logger, notifier all work independently

  • Sandboxed task execution — leveraging Docker for safe, isolated execution

  • Observability — streaming and storing logs in MongoDB, planning for real-time dashboards

Future Improvements

  • Dashboard UI with historical builds

  • Auth system (GitHub OAuth)

  • Artifacts upload/download

  • Slack integration for team notifications

  • Caching for faster re-builds

  • Cron job triggers (scheduled jobs)

  • ML model to detect flaky tests

  • SSO and secrets vault (e.g., HashiCorp Vault)

Conclusion - Building Argon

We have built a lightweight, extensible, and modular CI/CD Runner platform like GitHub Actions and Jenkins. Guess what Argon is open-source avd1729/Argon 🍾✨. Feel free to check it out, star it, fork it, break it, and build something on top of it. If you would love a finished, deployed production-grade product which you would use (don’t think about others, obviously), do drop a comment in this Issue.

If more people have this use case, I will build it. No bluff!

That’s it for now, thanks for being till the end, TLDR: I am someone who has more spare time to pursue what I love. Bye.

11
Subscribe to my newsletter

Read articles from Aravind M S directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Aravind M S
Aravind M S