CI/CD Pipelines for Go Services
A Go microservice pipeline runs tests and security checks, builds a versioned binary or image, pushes to a registry, and updates Kubernetes with the same immutable tag or digest.
Fast feedback and reproducible artifacts matter more than exotic orchestration syntax.
Summary
Standard stages are verify (go test, lint, govulncheck), build (go build or Docker multi-stage), publish (push image with semver or SHA tag), and deploy (kubectl apply, Helm upgrade, or Argo CD sync).
Pin Go 1.26.x in CI to match production toolchains.
Promote the exact image digest tested in staging; never rebuild at deploy time with different flags.
Recipe
Quick-reference recipe card - copy-paste ready.
# .github/workflows/service.yml (excerpt)
name: orders-api
on:
push:
branches: [main]
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.26.x"
cache: true
- run: go test ./...
- run: go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest && golangci-lint run
- run: go install golang.org/x/vuln/cmd/govulncheck@latest && govulncheck ./...
build-push:
needs: verify
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: |
VERSION=$(git describe --tags --always)
docker build \
--build-arg VERSION="$VERSION" \
--build-arg COMMIT="${{ github.sha }}" \
-t ghcr.io/myorg/orders-api:$VERSION .
docker push ghcr.io/myorg/orders-api:$VERSION
deploy-staging:
needs: build-push
runs-on: ubuntu-latest
steps:
- run: kubectl set image deployment/orders-api api=ghcr.io/myorg/orders-api:$VERSIONWhen to reach for this:
- Every Go service repository that ships containers to Kubernetes.
- Teams enforcing supply-chain gates before images reach a registry.
- Pipelines that must trace production incidents to git SHA and image digest.
Working Example
GitHub Actions workflow with test matrix, vulnerability scan, and digest-pinned deploy:
name: orders-api
on:
push:
branches: [main]
pull_request:
env:
REGISTRY: ghcr.io
IMAGE: ghcr.io/myorg/orders-api
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.26.x"
cache-dependency-path: go.sum
- run: go test -race -count=1 ./...
- run: go vet ./...
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.26.x"
- uses: golangci/golangci-lint-action@v6
with:
version: latest
vuln:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.26.x"
- run: go install golang.org/x/vuln/cmd/govulncheck@latest
- run: govulncheck ./...
image:
needs: [test, lint, vuln]
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
outputs:
digest: ${{ steps.build.outputs.digest }}
version: ${{ steps.meta.outputs.version }}
steps:
- uses: actions/checkout@v4
- id: meta
run: echo "version=$(git describe --tags --always)" >> "$GITHUB_OUTPUT"
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- id: build
uses: docker/build-push-action@v6
with:
push: true
tags: ${{ env.IMAGE }}:${{ steps.meta.outputs.version }}
build-args: |
VERSION=${{ steps.meta.outputs.version }}
COMMIT=${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
deploy:
needs: image
runs-on: ubuntu-latest
environment: staging
steps:
- uses: azure/setup-kubectl@v4
- run: |
kubectl set image deployment/orders-api \
api=${{ env.IMAGE }}@${{ needs.image.outputs.digest }}
kubectl rollout status deployment/orders-api --timeout=120sWhat this demonstrates:
- Parallel verify jobs fail fast before any image work runs.
govulncheckblocks merges with reachable known CVEs in dependencies.- Buildx GHA cache speeds multi-stage Go Docker builds.
- Deploy uses image digest from build output, not a rebuilt tag string.
Deep Dive
How It Works
- Verify stage exercises the same module graph developers use locally;
-racecatches data races before production. - Build stage produces one artifact per commit;
-ldflagsembed version metadata for runtime logs. - Registry stores immutable layers; signing (cosign) and SBOM generation attach at push time.
- Deploy stage updates desired state; Kubernetes rolls out per Deployment strategy.
Pipeline Stage Checklist
| Stage | Command / action | Fail criteria |
|---|---|---|
| Unit/integration | go test ./... | test or race failure |
| Static analysis | golangci-lint run | configured linters |
| Vuln scan | govulncheck ./... | reachable vulns per policy |
| Build | docker build or go build | compile error |
| Scan image | trivy/grype on pushed tag | critical CVE policy |
| Deploy | kubectl / GitOps sync | rollout timeout |
Go Notes
# Makefile targets local/CI parity
test:
go test -race -count=1 ./...
lint:
golangci-lint run
vuln:
govulncheck ./...
build:
CGO_ENABLED=0 go build -trimpath -ldflags="$(LDFLAGS)" -o bin/api ./cmd/api- Makefile or
magetargets keep CI commands identical to local developer workflows. - Commit
go.sum; rungo mod verifyin CI to detect tampering. - Use
GOPRIVATEand.netrcor credential helpers for private modules.
GitOps Variant
Instead of kubectl set image from CI, push manifest changes to a deploy repo.
Argo CD or Flux reconciles cluster state from git.
The pipeline updates kustomization.yaml image digest; humans review deploy PRs separately from app PRs.
Gotchas
- Rebuilding at deploy: different
GOFLAGSor cached modules can produce divergent binaries from CI build. - latest tag deploys: impossible to know what code runs; always pin semver or digest.
- Skipping tests on main: tag pipelines only on green merges; protect
mainwith required checks. - Lint version drift: pin golangci-lint version in CI when rule sets change behavior between releases.
- Registry credentials in logs: mask secrets; use OIDC to registries where supported.
Alternatives
- GitLab CI / CircleCI / Buildkite: same stage graph with different YAML; keep verify commands identical.
- Cloud Build / CodePipeline: managed builders close to GKE/EKS for faster pushes.
- ko publish: skip Dockerfile maintenance; publish images directly from Go import paths.
- Manual promote: human clicks deploy after staging soak; acceptable with strong audit requirements.
FAQs
Should CI run go test with -race on every PR?
Yes for services where concurrency matters; it adds time but catches races early.
Shard packages across matrix jobs if runtime grows large.
Where does integration testing fit?
Run httptest handler tests in unit stage; spin testcontainers or a staging namespace for DB integration on main or nightly.
Block production promote on staging smoke tests hitting /readyz.
How do I version Go modules in private monorepos?
Use replace directives locally only; CI should build from tagged module versions.
Set GOPRIVATE=*.corp.example and configure git credentials in the runner.
Digest versus semver tag in Kubernetes?
Digest is strongest immutability.
Semver tags are human-friendly; combine both in CI logs: 1.4.2@sha256:....
When should deploy run automatically?
Auto-deploy to staging on main merge; production requires manual approval or scheduled promote after error budget checks.
Related
- Deployment Basics - local build and kubectl hello
- Multi-Stage Docker Builds for Go - Dockerfile CI invokes
- Kubernetes Deployments, Services & Ingress - manifests deploy updates
- Deployment Best Practices - semver tags and rollback runbooks
- Dependency Scanning with govulncheck & SBOM - deeper supply-chain patterns
Stack versions: This page was written for Go 1.26.x (Green Tea GC default, go fix modernizers - verify patch at build), chi (latest - verify at build), gin (latest - verify at build), echo (latest - verify at build), google.golang.org/grpc (latest - verify at build), sigs.k8s.io/controller-runtime (latest - verify at build), kubebuilder (latest - verify at build), tinygo (latest - verify board targets at build), wazero (latest - verify at build), and golangci-lint (latest - verify linter set at build).