How to Set Up a CI/CD Pipeline for Go with GitLab CI

Set up a GitLab CI pipeline for Go by creating a .gitlab-ci.yml file with build, test, and deploy stages.

The push that breaks production

You push a commit to your Go project and immediately regret it. The build fails on your machine because you missed a dependency, or worse, a test passes locally but crashes in production because of a race condition. You spend twenty minutes debugging something that should have caught itself. This is where a CI/CD pipeline saves you from your own future mistakes. GitLab CI turns your repository into a factory that builds, tests, and ships your code every time you push.

CI/CD as your automated sous-chef

Think of your code repository as a restaurant kitchen. You are the chef tossing ingredients into the pan. CI/CD is the sous-chef standing next to you with a checklist. Every time you plate a dish, the sous-chef checks the temperature, tastes for salt, and ensures the garnish is fresh before it leaves the pass. If anything is wrong, the dish gets sent back immediately.

In GitLab, the .gitlab-ci.yml file is that checklist. It defines the stages of the assembly line: build, test, deploy. A runner is the worker that executes the steps. You write the recipe, GitLab provides the kitchen, and the runner does the cooking. The pipeline runs automatically on every push. You get feedback before anyone else sees the broken code.

Minimal pipeline

Here is the simplest pipeline that builds and tests a Go module. It uses the official Go image and runs the standard toolchain commands.

# Define the order of operations. GitLab runs stages sequentially.
stages:
  - build
  - test

# The build job compiles the binary to verify syntax and dependencies.
build:
  stage: build
  # Use the official Go image. It includes the toolchain and standard library.
  image: golang:1.21
  script:
    # Fetch dependencies from the module cache.
    - go mod download
    # Compile the main package into an executable named myapp.
    - go build -o myapp .

# The test job runs all tests. It fails if any test returns an error.
test:
  stage: test
  image: golang:1.21
  script:
    # Run tests in all packages recursively. The -v flag prints detailed output.
    - go test -v ./...

Commit this file and push it to your repository. GitLab detects the change and triggers the pipeline. The runner spins up a container, clones your code, and executes the jobs.

How the runner executes your pipeline

When the pipeline starts, the runner creates an isolated environment. It pulls the Docker image specified in image. For golang:1.21, this image contains the Go compiler, the standard library, and basic build tools. The runner checks out your repository into a temporary workspace.

The runner executes the build job first. It runs go mod download to populate the module cache. This command fetches dependencies listed in go.mod and stores them in the cache directory. Next, it runs go build. The compiler checks syntax, resolves types, and links the binary. If the compiler rejects the code with an error like undefined: variable, the job fails and the pipeline stops. You get a notification before anyone else sees the broken code.

If the build succeeds, the runner moves to the test stage. It runs go test ./.... Go's test runner executes every _test.go file in the module. The -v flag prints verbose output so you can see exactly which tests ran. If a test panics or returns an error, the job fails. The pipeline only passes if every stage completes without errors.

Pinning the Go version in the image is standard practice. Using golang:latest introduces drift. A minor version update might change behavior or deprecate a function. Pinning ensures the pipeline uses the same version as your local development environment.

Production pipeline with cache and rules

Real projects need more than a basic build. You want to cache dependencies to speed up runs, save the binary as an artifact, and deploy only on the main branch. Here is a production-ready pipeline.

stages:
  - build
  - test
  - deploy

variables:
  # Set the Go path for caching. This ensures the cache key matches the module path.
  GOPATH: /go

build:
  stage: build
  image: golang:1.21
  # Cache the module cache and build cache between runs.
  # The key changes per branch so branches don't pollute each other.
  cache:
    key: ${CI_COMMIT_REF_SLUG}
    paths:
      - /go/pkg/mod
      - /root/.cache/go-build
  script:
    - go mod download
    - go build -o myapp .
  # Artifacts pass files between jobs.
  # The binary is available to later stages if needed.
  artifacts:
    paths:
      - myapp
    expire_in: 1 hour

Cache aggressively. Build times kill developer flow.

The cache section tells GitLab to save specific directories between runs. The key determines when the cache is reused. Using ${CI_COMMIT_REF_SLUG} creates a separate cache for each branch. This prevents a feature branch from overwriting the cache for main. The paths list includes the module cache and the build cache. Go's build cache stores compiled packages. Reusing it speeds up subsequent builds significantly.

The artifacts section saves files generated by the job. The myapp binary is saved and made available to other jobs in the pipeline. Artifacts expire after the time specified in expire_in to save storage space.

deploy:
  stage: deploy
  image: alpine:latest
  # Rules replace the deprecated only/except syntax.
  # This job runs exclusively on the main branch.
  rules:
    - if: $CI_COMMIT_BRANCH == "main"
  script:
    # Upload the binary to a server or container registry.
    - echo "Deploying myapp to production..."

Rules control the flow. Deploy only when you trust the branch.

The rules section defines when a job runs. This job triggers only when the commit is on the main branch. The if condition checks the built-in variable $CI_COMMIT_BRANCH. You can add more rules to trigger on tags or merge requests. Using rules is the modern approach. The older only and except keywords are deprecated and lack flexibility.

Enforcing module hygiene

Go modules require go.mod and go.sum to be consistent. If you add a dependency locally but forget to run go mod tidy, the files might drift. The pipeline should catch this. Add a lint job to verify module hygiene.

lint:
  stage: build
  image: golang:1.21
  script:
    # Tidy the module files. This adds missing and removes unused dependencies.
    - go mod tidy
    # Fail if go.mod or go.sum changed after tidy.
    # This ensures the files are committed in a clean state.
    - git diff --exit-code go.mod go.sum

Module files must match. Tidy before you push.

The go mod tidy command updates go.mod and go.sum to match the source code. It adds missing dependencies and removes unused ones. The git diff --exit-code command checks if the files changed. If tidy modified the files, git diff exits with a non-zero code and the job fails. This forces developers to commit clean module files. The community expects go.mod and go.sum to be up to date. This check prevents merge conflicts and ensures reproducible builds.

Pitfalls and compiler errors

Pipelines fail for specific reasons. Understanding common pitfalls saves debugging time.

If you forget to pin the Go version in the image, the pipeline might use a newer version than your code supports. The compiler rejects the build with go.mod requires go 1.22 but go version is go1.21 if the image is too old. Always pin the image version to match your go.mod directive.

If you use rules incorrectly, jobs might run when you don't expect them. A common mistake is using $CI_COMMIT_BRANCH in a merge request pipeline. Merge requests don't have a branch variable in the same way. Use $CI_MERGE_REQUEST_TARGET_BRANCH_NAME for merge request logic.

Running go test without the -race flag misses data races. The race detector catches concurrent access to shared memory. Add -race to your test script. It slows tests down but catches bugs that are hard to reproduce locally. The worst goroutine bug is the one that never logs.

Another pitfall is ignoring formatting. Go has a strict convention: gofmt is standard practice. Don't argue about indentation; let the tool decide. Most editors run it on save. In CI, you can enforce this with gofmt -l .. If the command outputs filenames, the formatting is wrong and the job should fail. This keeps the codebase consistent without style debates.

The community accepts verbose error handling because it makes the unhappy path visible. If you suppress errors with _, you might hide failures that only surface in CI. The compiler forces you to acknowledge errors. Use _ sparingly and only when you have verified the error is safe to ignore.

Decision matrix

Use a basic go build and go test pipeline when you are starting a new project and need immediate feedback on syntax and tests.

Use caching with cache keys based on CI_COMMIT_REF_SLUG when your module has many dependencies and you want to reduce build times.

Use rules with branch conditions when you need to deploy only from specific branches like main or release.

Use the -race flag in your test script when your code uses goroutines or channels to catch data races early.

Use gofmt -l . in a lint job when you want to enforce consistent formatting across the team without manual review.

Use artifacts to pass binaries between stages when your deploy job needs the compiled output from the build job.

Use go mod tidy with git diff in a lint job when you want to ensure module files are clean and reproducible.

CI is your safety net. Make it tight.

Where to go next