Build a CI/CD Runner in Go (Step-by-Step Guide)
In this tutorial, you'll learn about Build a CI/CD Runner in Go (Step. We cover key concepts, practical examples, and best practices to help you understand and apply this topic effectively.
Build a self-hosted CI/CD runner in Go that polls a job queue from a remote API, executes pipeline steps inside isolated Docker containers, and streams logs back to the orchestrator.
What You'll Build
You will build a minimal CI runner — similar to GitHub Actions self-hosted runners or GitLab CI runners. It polls for pending jobs, pulls a job configuration, spawns Docker containers for each step, captures output, and reports pass/fail results to the API server.
Why Build Your Own Runner?
Understanding how CI runners work gives you control over your pipeline infrastructure. Instead of paying per-minute for managed runners, you can run your own on spare VMs or on-premise hardware. At DodaTech, our internal build pipeline uses a custom runner to compile Doda Browser across 12 architectures simultaneously — saving thousands of dollars per month in CI costs.
Prerequisites
Step 1: Project Setup
mkdir ci-runner
cd ci-runner
go mod init ci-runner
go get github.com/docker/docker/client
Step 2: Job Poller
The runner polls an API endpoint for pending jobs. Each job contains steps — commands to execute in sequence inside Docker containers.
// poller.go
package main
import (
"encoding/json"
"fmt"
"net/http"
"time"
)
type Job struct {
ID string `json:"id"`
Image string `json:"image"`
Steps []string `json:"steps"`
RepoURL string `json:"repo_url"`
}
type Poller struct {
APIURL string
Token string
Client *http.Client
}
func NewPoller(apiURL, token string) *Poller {
return &Poller{
APIURL: apiURL,
Token: token,
Client: &http.Client{Timeout: 10 * time.Second},
}
}
func (p *Poller) FetchJob() (*Job, error) {
req, _ := http.NewRequest("GET", p.APIURL+"/jobs/pending", nil)
req.Header.Set("Authorization", "Bearer "+p.Token)
resp, err := p.Client.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil, nil
}
var job Job
if err := json.NewDecoder(resp.Body).Decode(&job); err != nil {
return nil, fmt.Errorf("decode failed: %w", err)
}
return &job, nil
}
Expected output: When you run go build, it compiles without errors. The poller returns nil, nil when no jobs are available.
Step 3: Docker Executor
Each step runs inside a fresh container. We use the Docker SDK to pull images, create containers, run commands, and capture output.
// executor.go
package main
import (
"bytes"
"context"
"io"
"log"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/client"
"github.com/docker/docker/pkg/stdcopy"
)
type Executor struct {
client *client.Client
}
func NewExecutor() (*Executor, error) {
cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
if err != nil {
return nil, err
}
return &Executor{client: cli}, nil
}
func (e *Executor) RunStep(ctx context.Context, image, command string, logStream io.Writer) error {
reader, err := e.client.ImagePull(ctx, image, types.ImagePullOptions{})
if err != nil {
return err
}
io.Copy(io.Discard, reader)
reader.Close()
resp, err := e.client.ContainerCreate(ctx, &container.Config{
Image: image,
Cmd: []string{"sh", "-c", command},
Tty: false,
}, nil, nil, nil, "")
if err != nil {
return err
}
defer e.client.ContainerRemove(ctx, resp.ID, types.ContainerRemoveOptions{})
if err := e.client.ContainerStart(ctx, resp.ID, types.ContainerStartOptions{}); err != nil {
return err
}
out, err := e.client.ContainerLogs(ctx, resp.ID, types.ContainerLogsOptions{
ShowStdout: true,
ShowStderr: true,
Follow: true,
})
if err != nil {
return err
}
stdcopy.StdCopy(logStream, logStream, out)
out.Close()
insp, _ := e.client.ContainerInspect(ctx, resp.ID)
if insp.State.ExitCode != 0 {
return fmt.Errorf("step failed with exit code %d", insp.State.ExitCode)
}
return nil
}
Expected output: The executor pulls alpine:latest and runs echo hello returning no errors. Exit code 0 means success.
Step 4: Runner Loop
The main loop combines polling and execution, reporting results back to the API.
// main.go
package main
import (
"bytes"
"context"
"encoding/json"
"log"
"net/http"
"os"
"time"
)
func reportStatus(apiURL, token string, job Job, success bool, logData string) {
body, _ := json.Marshal(map[string]interface{}{
"job_id": job.ID,
"success": success,
"log": logData,
})
req, _ := http.NewRequest("POST", apiURL+"/jobs/result", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
http.DefaultClient.Do(req)
}
func main() {
apiURL := os.Getenv("CI_API_URL")
token := os.Getenv("CI_TOKEN")
if apiURL == "" || token == "" {
log.Fatal("CI_API_URL and CI_TOKEN must be set")
}
poller := NewPoller(apiURL, token)
executor, err := NewExecutor()
if err != nil {
log.Fatalf("executor: %v", err)
}
for {
job, err := poller.FetchJob()
if err != nil {
log.Printf("poll error: %v", err)
time.Sleep(5 * time.Second)
continue
}
if job == nil {
time.Sleep(5 * time.Second)
continue
}
log.Printf("executing job %s: %d steps", job.ID, len(job.Steps))
var buf bytes.Buffer
success := true
for i, step := range job.Steps {
log.Printf(" step %d: %s", i+1, step)
if err := executor.RunStep(context.Background(), job.Image, step, &buf); err != nil {
log.Printf(" step %d failed: %v", i+1, err)
success = false
break
}
}
reportStatus(apiURL, token, *job, success, buf.String())
log.Printf("job %s complete (success=%v)", job.ID, success)
}
}
Architecture
flowchart TB
subgraph "CI Server"
API[Job Queue API]
DB[(PostgreSQL)]
end
subgraph "Runner Machine"
P[Poller]
E[Executor]
D[Docker Daemon]
end
P -->|GET /jobs/pending| API
API -->|returns Job JSON| P
P -->|steps| E
E -->|ImagePull| D
E -->|ContainerCreate| D
E -->|logs| P
P -->|POST /jobs/result| API
API --> DB
Common Errors
1. Docker socket not accessible
The runner needs access to /var/run/docker.sock. Run the runner as a user in the docker group, or mount the socket inside the runner's own container: -v /var/run/docker.sock:/var/run/docker.sock.
2. Image pull timeout
Large images take time to download. Set a reasonable context timeout on ImagePull. Our code uses the default background context — add ctx, cancel := context.WithTimeout(ctx, 5*time.Minute) in production.
3. Job poll returns 401
The API token is missing or invalid. Verify CI_TOKEN is set correctly and matches what the API server expects.
4. Steps run on wrong base image
Each step's command executes inside the container image specified in the job. If the image doesn't have sh, the step fails. Use images like alpine, ubuntu, or python:3.11 that include a shell.
5. Log output is interleaved
stdout and stderr are multiplexed by Docker. We use stdcopy.StdCopy to demux them. Without this, log output appears garbled.
Practice Questions
1. How does the runner know which job to pick?
The poller calls GET /jobs/pending. The API server returns the next available job and atomically marks it as claimed so other runners don't pick it up.
2. Why use Docker containers for each step? Containers provide isolation — each step starts from a clean filesystem. Dependencies from one step don't leak into another. If a step corrupts the environment, the next step is unaffected.
3. What happens if the runner crashes mid-job? The job stays in a "running" state on the API server. A timeout mechanism should mark stale jobs as failed after a grace period, making them visible for debugging.
4. Challenge: Job artifacts
Modify the executor to upload files from the working directory to object storage (S3/minio) after a successful job run. Add an artifacts field to the Job struct with glob patterns.
5. Challenge: Parallel steps
Extend the runner to support parallel steps that execute in separate goroutines. Report individual step statuses instead of a single all-or-nothing result.
FAQ
Next Steps
- Add Kubernetes job execution as an alternative to Docker
- Implement secret injection via HashiCorp Vault or environment variables
- Explore the Docker SDK for advanced features like volume mounts and network isolation
- Build a web dashboard to visualize pipeline status across all runners
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro