CI/CD Strategies for Go Monorepos

Practical CI/CD pipeline strategies for Go monorepos, covering affected service detection, parallel builds, caching, and deployment orchestration.

technical8 min readBy Klivvr Engineering
Share:

A monorepo without a good CI/CD pipeline is a liability. Every commit triggers builds for every service, tests run serially for an hour, and deployments become an all-or-nothing affair. At Klivvr, we learned this the hard way during Andromeda's first months. Our initial CI configuration treated the monorepo like a single project: one build, one test suite, one deployment. As the number of services grew from three to fifteen, pipeline duration climbed from five minutes to forty. Engineers started stacking pull requests to avoid waiting, which made code review harder and merges riskier.

We overhauled the pipeline with three goals: only build and test what changed, parallelize aggressively, and deploy services independently. This article describes the strategies and tooling that brought our pipeline back to single-digit minutes while maintaining correctness.

Affected Service Detection

The cornerstone of an efficient monorepo CI pipeline is knowing which services are affected by a given commit. A change to internal/accounts/domain/account.go should trigger builds and tests for the accounts service but not for the notifications service. A change to pkg/pagination/cursor.go should trigger builds for every service that imports that package.

We built a small Go tool called affected that computes the set of affected services for a given Git diff:

// scripts/affected/main.go
package main
 
import (
    "fmt"
    "os"
    "os/exec"
    "path/filepath"
    "strings"
)
 
// serviceRoots maps service names to their root directories.
var serviceRoots = map[string]string{
    "gateway":       "cmd/gateway",
    "accounts":      "cmd/accounts",
    "payments":      "cmd/payments",
    "notifications": "cmd/notifications",
    "kyc":           "cmd/kyc",
}
 
// sharedPaths are paths that, if changed, affect all services.
var sharedPaths = []string{
    "pkg/",
    "internal/shared/",
    "proto/",
    "go.mod",
    "go.sum",
}
 
func main() {
    base := "origin/main"
    if len(os.Args) > 1 {
        base = os.Args[1]
    }
 
    changedFiles, err := gitDiff(base)
    if err != nil {
        fmt.Fprintf(os.Stderr, "git diff: %v\n", err)
        os.Exit(1)
    }
 
    affected := computeAffected(changedFiles)
    for _, svc := range affected {
        fmt.Println(svc)
    }
}
 
func gitDiff(base string) ([]string, error) {
    cmd := exec.Command("git", "diff", "--name-only", base+"...HEAD")
    out, err := cmd.Output()
    if err != nil {
        return nil, err
    }
    lines := strings.Split(strings.TrimSpace(string(out)), "\n")
    return lines, nil
}
 
func computeAffected(changedFiles []string) []string {
    affectedSet := make(map[string]bool)
 
    for _, file := range changedFiles {
        // Check if the change is in a shared path
        for _, shared := range sharedPaths {
            if strings.HasPrefix(file, shared) {
                // Shared change: all services are affected
                for name := range serviceRoots {
                    affectedSet[name] = true
                }
                break
            }
        }
 
        // Check if the change is in a service-specific path
        for name := range serviceRoots {
            serviceInternal := filepath.Join("internal", name) + "/"
            serviceCmd := filepath.Join("cmd", name) + "/"
            serviceMigrations := filepath.Join("migrations", name) + "/"
 
            if strings.HasPrefix(file, serviceInternal) ||
                strings.HasPrefix(file, serviceCmd) ||
                strings.HasPrefix(file, serviceMigrations) {
                affectedSet[name] = true
            }
        }
    }
 
    result := make([]string, 0, len(affectedSet))
    for name := range affectedSet {
        result = append(result, name)
    }
    return result
}

This tool is intentionally simple. It uses file path prefixes rather than Go's dependency graph because path-based detection is fast, predictable, and easy to debug. For most changes, the result is accurate. For edge cases (like a refactor that changes an interface in internal/shared/), the sharedPaths list ensures we err on the side of building more, not less.

Parallel Build and Test Matrix

Once we know which services are affected, we use a CI matrix strategy to build and test them in parallel. Here is a simplified GitHub Actions workflow:

// .github/workflows/ci.yml (conceptual, shown as YAML-in-comments)
//
// jobs:
//   detect:
//     runs-on: ubuntu-latest
//     outputs:
//       services: ${{ steps.affected.outputs.services }}
//     steps:
//       - uses: actions/checkout@v4
//         with:
//           fetch-depth: 0
//       - run: |
//           services=$(go run ./scripts/affected | jq -Rc '[., inputs]')
//           echo "services=$services" >> "$GITHUB_OUTPUT"
//         id: affected
//
//   build-and-test:
//     needs: detect
//     if: needs.detect.outputs.services != '[]'
//     strategy:
//       matrix:
//         service: ${{ fromJson(needs.detect.outputs.services) }}
//     runs-on: ubuntu-latest
//     steps:
//       - uses: actions/checkout@v4
//       - uses: actions/setup-go@v5
//         with:
//           go-version: '1.22'
//           cache: true
//       - run: go build ./cmd/${{ matrix.service }}
//       - run: go test ./internal/${{ matrix.service }}/...

The detect job runs first and outputs a JSON array of affected service names. The build-and-test job fans out into parallel runners, one per affected service. On a typical pull request that touches one service, the pipeline runs a single build-and-test job. On a pull request that touches shared code, all services run in parallel.

Caching Strategies

Go builds are fast, but they are faster with caching. We use two levels of caching:

Go module cache. The actions/setup-go action caches $GOPATH/pkg/mod based on the hash of go.sum. Since we use a single module, the go.sum file is stable across most commits, so the module cache hit rate is high.

Go build cache. Go's build cache (typically at ~/.cache/go-build) stores compiled packages. We cache this directory with a key that includes the Go version and the hash of all .go files. This means that if the previous pipeline compiled a package and the source has not changed, the current pipeline reuses the compiled artifact.

// Effective caching configuration (pseudocode)
//
// cache-key: go-build-${{ runner.os }}-${{ hashFiles('go.sum') }}-${{ hashFiles('**/*.go') }}
// restore-keys:
//   - go-build-${{ runner.os }}-${{ hashFiles('go.sum') }}-
//   - go-build-${{ runner.os }}-

The restore-key fallback strategy is important. Even if the exact .go hash does not match, a partial cache from a previous build still provides significant speedup because most packages have not changed.

We also cache Docker layers for container builds. Each service has a Dockerfile that uses multi-stage builds:

// Dockerfile for a service (shown as comment)
//
// FROM golang:1.22 AS builder
// WORKDIR /src
// COPY go.mod go.sum ./
// RUN go mod download
// COPY . .
// ARG SERVICE
// RUN CGO_ENABLED=0 go build -o /bin/service ./cmd/${SERVICE}
//
// FROM gcr.io/distroless/static
// COPY --from=builder /bin/service /service
// ENTRYPOINT ["/service"]

The COPY go.mod go.sum followed by go mod download step is a classic Docker caching pattern. Since go.mod and go.sum change infrequently, the module download layer is cached across builds.

Deployment Orchestration

Each service in Andromeda is deployed independently. A merge to main triggers the detect-and-deploy pipeline, which builds container images only for affected services and deploys them to Kubernetes.

We use a simple convention: each service has a Kubernetes manifest directory at deploy/<service>/. The deployment pipeline renders these manifests with the new image tag and applies them:

// scripts/deploy.go (simplified)
package main
 
import (
    "fmt"
    "os"
    "os/exec"
)
 
func main() {
    service := os.Getenv("SERVICE")
    imageTag := os.Getenv("IMAGE_TAG")
    namespace := os.Getenv("K8S_NAMESPACE")
 
    if service == "" || imageTag == "" {
        fmt.Fprintln(os.Stderr, "SERVICE and IMAGE_TAG are required")
        os.Exit(1)
    }
 
    // Render the manifest with the new image tag
    manifestDir := fmt.Sprintf("deploy/%s", service)
    image := fmt.Sprintf("registry.klivvr.com/andromeda/%s:%s", service, imageTag)
 
    // Use kustomize to set the image
    kustomize := exec.Command("kustomize", "edit", "set", "image",
        fmt.Sprintf("SERVICE_IMAGE=%s", image))
    kustomize.Dir = manifestDir
    kustomize.Stderr = os.Stderr
    if err := kustomize.Run(); err != nil {
        fmt.Fprintf(os.Stderr, "kustomize: %v\n", err)
        os.Exit(1)
    }
 
    // Apply to the cluster
    apply := exec.Command("kustomize", "build", manifestDir)
    kubectl := exec.Command("kubectl", "apply", "-n", namespace, "-f", "-")
    kubectl.Stdin, _ = apply.StdoutPipe()
    kubectl.Stdout = os.Stdout
    kubectl.Stderr = os.Stderr
 
    if err := kubectl.Start(); err != nil {
        fmt.Fprintf(os.Stderr, "kubectl start: %v\n", err)
        os.Exit(1)
    }
    if err := apply.Run(); err != nil {
        fmt.Fprintf(os.Stderr, "kustomize build: %v\n", err)
        os.Exit(1)
    }
    if err := kubectl.Wait(); err != nil {
        fmt.Fprintf(os.Stderr, "kubectl apply: %v\n", err)
        os.Exit(1)
    }
 
    fmt.Printf("Deployed %s with image %s\n", service, image)
}

Deployments are staged: services roll out to a canary environment first, then to production after automated smoke tests pass. If a service's smoke tests fail, the deployment is halted and the team is notified. Other services' deployments proceed independently.

Linting and Static Analysis

Beyond builds and tests, our pipeline runs a comprehensive suite of linters. We use golangci-lint with a shared configuration at the repository root:

// .golangci.yml (key sections, shown as comment)
//
// linters:
//   enable:
//     - errcheck
//     - gosimple
//     - govet
//     - ineffassign
//     - staticcheck
//     - unused
//     - gofumpt
//     - godot
//     - misspell
//     - prealloc
//     - revive
//
// issues:
//   exclude-rules:
//     - path: _test\.go
//       linters:
//         - errcheck

Linting runs in parallel with the build-and-test matrix. Since linters operate on the full codebase and are fast, we do not scope them to affected services. The linting job typically completes in under two minutes.

Conclusion

CI/CD for a Go monorepo is not fundamentally different from CI/CD for a polyrepo setup. The key insight is that you need an efficient mechanism for determining what changed and scoping your pipeline accordingly. Affected service detection, parallel matrix builds, aggressive caching, independent deployments, and comprehensive linting form a pipeline that scales with the number of services rather than against it.

The tooling we built is deliberately simple. The affected service detector is a hundred lines of Go. The deployment script is similarly straightforward. We chose simplicity over flexibility because CI pipelines are infrastructure that every engineer depends on. When they break, they need to be debuggable by anyone on the team, not just the platform engineers who wrote them. Start simple, measure your pipeline's performance, and optimize the bottlenecks you actually observe.

Related Articles

technical

Testing Strategies for Go Backend Services

A comprehensive guide to testing Go backend services, covering unit tests, integration tests, end-to-end tests, table-driven patterns, test fixtures, and strategies for testing gRPC and NATS-based systems.

11 min read
business

How Monorepos Boost Team Productivity

An exploration of how monorepo architecture improves developer velocity, code quality, and cross-team collaboration, based on real-world experience with Andromeda.

9 min read
technical

Observability in Go: Tracing, Metrics, and Logging

A practical guide to implementing observability in Go backend services using OpenTelemetry for tracing, Prometheus for metrics, and structured logging with log/slog.

7 min read