The first CI/CD pipeline I built for a Go microservice on the Standard Chartered migration was too ambitious. I tried to wire up canary deploys, automated rollback, and multi region promotion before the service had even passed its first integration test. It collapsed under its own complexity within a week, and I ended up ripping half of it out.
Since then I have built pipelines for a dozen Go services, and the pattern that actually survives contact with production is the opposite of ambitious. Start with the smallest pipeline that can safely ship code, then add stages only when a real failure proves you need them.
This is the sequence I follow every time, from an empty repository to a service running in production, with nothing skipped and nothing added for show.
Start With a Pipeline That Only Builds and Tests
Before you touch deployment, your pipeline needs one job: prove the code compiles and the tests pass. This sounds obvious, but I have seen teams jump straight to deploy automation and skip this step, which means broken code reaches later stages before anyone notices.
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.22'
- run: go build ./...
- run: go vet ./...
- run: go test ./... -race -cover
Run this on every pull request and every push to main. Nothing else in the pipeline should exist until this stage is green and stays green. If your test suite is flaky at this point, fix that before adding anything downstream, because a flaky pipeline trains engineers to ignore failures.
Pin Your Go Version
Do not let the pipeline resolve to whatever Go version happens to be installed on the runner. Pin it explicitly and match it to your go.mod. I have debugged more than one production incident that traced back to a CI runner silently upgrading its default Go version and exposing a behavior difference nobody tested for.
Add Static Analysis and Security Scanning Early
Once build and test are stable, add static analysis before you add deployment. Catching issues here is cheap. Catching them after a deploy is not.
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: golangci/golangci-lint-action@v6
with:
version: latest
I also run govulncheck against the dependency tree on every push to main. Go microservices in financial systems pull in a lot of indirect dependencies, and a known vulnerability sitting three levels deep in your module graph is not something you want to discover during an audit.
vuln-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: go install golang.org/x/vuln/cmd/govulncheck@latest
- run: govulncheck ./...
Build and Push the Container Image
With tests and static analysis passing reliably, the next stage builds the container image. Keep the Dockerfile minimal and use a multi stage build so the final image contains only the compiled binary, not the Go toolchain.
FROM golang:1.22 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o service ./cmd/service
FROM gcr.io/distroless/static-debian12
COPY --from=builder /app/service /service
ENTRYPOINT ["/service"]
Distroless base images cut attack surface significantly compared to a full Debian or Alpine image. There is no shell, no package manager, and nothing an attacker can use if they land inside the container. This matters more than it seems until you are the one explaining to a security team why your production image has bash installed.
Tag every image with the git commit SHA, never with latest. I learned this the hard way when a rollback attempt pulled a latest tag that had already moved forward, redeploying the exact bug we were trying to roll back from.
build-push:
needs: [test, lint, vuln-scan]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/build-push-action@v6
with:
push: true
tags: registry.example.com/service:${{ github.sha }}
Deploy to Staging Automatically, Deploy to Production Manually
This is the split that took me the longest to get right. Every merge to main should deploy to staging without a human involved. Production should require a deliberate action, even if that action is just clicking approve.
deploy-staging:
needs: build-push
runs-on: ubuntu-latest
steps:
- run: kubectl set image deployment/service service=registry.example.com/service:${{ github.sha }} -n staging
deploy-production:
needs: deploy-staging
runs-on: ubuntu-latest
environment:
name: production
steps:
- run: kubectl set image deployment/service service=registry.example.com/service:${{ github.sha }} -n production
The environment: production block in GitHub Actions lets you attach a required reviewer to that job. Nothing reaches production without someone explicitly approving that specific commit SHA. This is not about distrust of automation. It is about having one deliberate checkpoint before a change reaches customers.
Run Smoke Tests Before You Call It Done
A deploy that completes without errors is not the same as a service that works. After every production deploy, run a small set of smoke tests against real endpoints before marking the deploy successful.
func TestSmokeHealthEndpoint(t *testing.T) {
resp, err := http.Get("https://service.example.com/health")
if err != nil {
t.Fatalf("health check failed: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
}
Keep this suite small, five to ten checks against the critical paths. Its job is to catch the deploy that technically succeeded but left the service unable to reach its database or an upstream dependency.
Wire Up Rollback Before You Need It
Do not wait for a bad deploy to figure out how to roll back. Build the rollback path at the same time you build the deploy path, and test it in staging before you ever need it in production.
rollback:
runs-on: ubuntu-latest
environment:
name: production
steps:
- run: kubectl rollout undo deployment/service -n production
Because every image is tagged with a commit SHA, kubectl rollout undo reliably returns to the previous known good image. Test this command in staging on a normal week, not during an incident, so the first time you run it is not also the first time you find out it does not work.
What I Would Skip on a First Pipeline
Canary deployments, automated performance regression gates, and multi region promotion all sound valuable, and eventually they are. But none of them matter if your basic build, test, and deploy stages are not rock solid first. I added all three to a service once before the team trusted the core pipeline, and it meant every failure required debugging three new systems at once instead of one.
Add complexity only after the simple pipeline has run reliably for a few weeks and a specific, real failure shows you what the next stage needs to catch. A pipeline built ahead of actual need is speculative infrastructure, and speculative infrastructure is exactly the kind of cleverness that costs more than it saves.
Conclusion
A CI/CD pipeline for a Go microservice does not need to be sophisticated to be effective. It needs to build reliably, test honestly, deploy to staging without friction, and require one deliberate checkpoint before production. Everything past that is optional until a real incident proves otherwise.
The pipelines that have served me best across every migration are the boring ones. They fail loudly when something is wrong, they roll back cleanly when something breaks in production, and nobody on the team needs a walkthrough to understand what a given stage does.
Build the boring pipeline first. Earn the right to add complexity later.