How to set up a CI/CD pipeline for a Go microservice from zero to production

软件 工程师 都柏林

软件 工程师 都柏林

工程师 精心 打造 可扩展

超越 代码 本身

他们 怎么说

一起 构建

消息 已收到

隐私 政策

使用 条款

Cookie 政策

免责声明

最新 动态

精选 作品

作品 展示

我们 提供 什么

服务 的行业

屏幕 之外

共同 构建

View View
Nben Malla
Nben Malla

Software Engineer

Nben Malla is a software engineer based in Dublin, Ireland, specializing in microservices architecture, legacy system modernization and full stack development.

With experience across FinTech and SaaS, he has delivered scalable backend systems for global clients using Go, Java, Python, Django and Laravel, collaborating with teams across Nepal, Ireland, the Netherlands, New Zealand and the United States.

From leading legacy modernization for global banking clients to architecting microservices and distributed systems, the focus has always been to understand the problem deeply, build it right and deliver software that lasts.

  • 阅读文章 阅读文章

    Tutorials 7 mins

    How to set up a CI/CD pipeline for a Go microservice from zero to production

    Nben M. 27 Jul, 2026 7 mins

    How to set up a CI/CD pipeline for a Go microservice from zero to production

    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.

    yaml
    # .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.

    yaml
    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.

    yaml
    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.

    dockerfile ·
    markdown
    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.

    yaml
    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.

    yaml
    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.

    go
    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.

    yaml
    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.