Skip to main content

Build and Deploy

For developers with .NET (C#) experience, this guide explains single binaries, cross-compilation, Docker multi-stage builds, and CI/CD. A big strength of Go is that what dotnet publish does takes a single go build command, and the resulting static binary needs no runtime installation and can be deployed in a tiny container image. This guide covers build flags, embedding versions, cross-compilation, containerization, and deploying from GitHub Actions to Azure.

1. What This Document Covers​

  • go build flags (-ldflags, etc.)
  • CGO_ENABLED and static linking
  • Building minimal Docker images
  • Deploying with GitHub Actions / Azure

go build flags (-ldflags, etc.)​

go build produces one executable from the specified package (usually a main package). Commonly used flags are as follows.

FlagMeaning
-o pathSpecify the output file name or directory
-trimpathRemove build-environment file paths from the binary. Improves reproducibility and avoids leaking local paths
-ldflags '...'Flags for the linker. -s -w shrinks the binary and -X embeds string variables
-tags nameEnable build tags (switches //go:build constraints)
-raceEnable the data race detector (mainly for tests; normally not used for production builds)
-buildvcs=falseDisable embedding version control information

Main uses of -ldflags:

  • -s omits the symbol table and -w omits DWARF debug information. The binary gets smaller, but investigating it with a debugger becomes harder.
  • -X import.path.variable=value sets the value of a string variable at link time. It is used to embed a version or commit hash. The target must be a var (not a const) of string type, with no initializer or a constant string initializer. For a variable in the main package, write main.version.

CGO_ENABLED and static linking​

A Go binary built only from plain Go code is a static binary with no library dependencies. However, when cgo (calling C libraries) is enabled, the binary may be dynamically linked against a C library such as glibc. Specifying CGO_ENABLED=0 builds with pure Go only, without cgo.

  • The standard library's net and os/user have cgo-based implementations, so on Linux a build with cgo enabled may dynamically link libc. With CGO_ENABLED=0 they switch to the pure Go implementations.
  • In environments without a C compiler, and when cross-compiling for a different OS or architecture, cgo is disabled by default.
  • Libraries that require cgo, such as SQLite's mattn/go-sqlite3, cannot be built with CGO_ENABLED=0. Either choose an implementation that doesn't use cgo (such as modernc.org/sqlite) or run on an image that includes glibc.

Building minimal Docker images​

With a static binary, a multi-stage build that separates the build image from the runtime image keeps the runtime image tiny.

Runtime imageCharacteristics
scratchCompletely empty. If you need CA certificates, time zone data, or /etc/passwd, you must COPY them yourself
gcr.io/distroless/static-debian12A minimal image containing CA certificates, time zone data, and a non-root user. For static binaries
gcr.io/distroless/base-debian12Includes glibc. For dynamically linked binaries that use cgo
alpineA small image with a shell and a package manager. It uses musl libc, so be careful when using cgo

The :nonroot tag of distroless/static is usually the easiest choice. If the lack of a shell makes debugging hard, temporarily use the :debug tag, which includes a shell.

Deploying with GitHub Actions / Azure​

The typical flow is test, build the container image, push to a registry, and deploy. On Azure, you put the image in Azure Container Registry (ACR) and run it on Azure Container Apps, AKS, App Service for Containers, and so on.

  • For authentication from GitHub Actions to Azure, use federated authentication with OpenID Connect (OIDC) (azure/login) rather than long-lived secrets.
  • Tag images with an immutable tag such as the commit SHA, and don't deploy with latest alone.
  • Go binaries start quickly and use little memory, which makes them a good fit for scaling out containers.

2. .NET Mapping​

.NET (C#)Go
dotnet publish (self-contained / AOT)go build (static binary by default)
RID specificationGOOS / GOARCH
mcr.microsoft.com/dotnet/aspnetscratch / distroless / alpine

Additional mappings that complement the table above:

.NET (C#)Go
dotnet publish -c Releasego build (optimization is always on; there is no configuration distinction)
-r linux-x64 --self-containedGOOS=linux GOARCH=amd64 go build
PublishSingleFileA single binary by default
PublishTrimmed / Native AOTNot needed (at most, strip symbol information with -ldflags '-s -w')
AssemblyInformationalVersion / -p:Version=-ldflags '-X main.version=...'
Deterministic / PathMap-trimpath
Output directory of dotnet publish-o bin/app
mcr.microsoft.com/dotnet/sdk (build) and aspnet (run)golang (build) and distroless/static (run)
Incremental builds in dotnet buildThe build cache (go env GOCACHE) works automatically
#if DEBUG / conditional compilationBuild tags (//go:build) and -tags

3. Common Pitfalls for C# Developers​

No runtime installation or size-optimization settings needed​

.NET offers many options, such as framework-dependent, self-contained, Single File, Trimming, and Native AOT, and behavior changes with the combination. Go always produces a self-contained native binary. You don't need to install Go on the target machine; you just copy the binary and run it.

Cross-compilation takes only environment variables​

You can build a binary for another OS or architecture on your current machine just by specifying GOOS and GOARCH. Check the supported combinations with go tool dist list. Building a Windows .exe from WSL or a Linux CI needs no additional SDK.

With cgo enabled, the binary is not static​

If cgo is enabled unintentionally, the binary may fail to start on distroless/static or scratch with exec format error or no such file or directory (the dynamic linker is not found). For container builds, it is safest to specify CGO_ENABLED=0 explicitly. ldd bin/app or file bin/app shows whether the binary is dynamically linked.

-X doesn't set the value​

-X has no effect in the following cases. Because it is silently ignored, it is easy to miss.

  • The target is a const, or is not a string type
  • The variable path is wrong (for packages other than main, specify the full import path)
  • The initializer is not a constant, such as a function call
// Works: a string var
var version = "dev"

// Doesn't work: a const can't be overwritten
const version = "dev"

scratch has no CA certificates or time zones​

scratch is literally empty, so an HTTPS client fails certificate verification and time.LoadLocation("Asia/Tokyo") returns an error. distroless/static includes both. When using scratch, COPY /etc/ssl/certs/ca-certificates.crt from the builder, and embed time zone data into the binary with import _ "time/tzdata" (this adds about 450 KB to the binary).

Debugging and HEALTHCHECK are hard in shell-less images​

distroless has no shell or curl, so you can't log in with docker exec or write HEALTHCHECK CMD curl .... Leave health checks to the orchestrator (Kubernetes probes, Azure Container Apps probes). When you need to investigate, use the :debug tag.

Dockerfile ordering that leverages the build cache​

Copy only go.mod and go.sum first and run go mod download, then copy the source. As long as dependencies don't change, the download layer is reused. This is the same idea as the standard .NET Dockerfile practice of running dotnet restore first.

You embed version and commit information yourself​

Nothing like AssemblyInformationalVersion works out of the box. At release time, pass the result of git describe and the like with -ldflags -X. Since Go 1.18, runtime/debug.ReadBuildInfo() can read VCS information such as the revision, but for a version string such as a tag name, passing it with -X is easier.

4. Code Examples​

Example 1: Embedding a version (compared with C#)​

C#:

// Embedded in the .csproj
// dotnet publish -c Release -p:Version=1.2.3
var version = typeof(Program).Assembly
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion;
Console.WriteLine(version);

Go:

package main

import (
"fmt"
"runtime/debug"
)

// Overwritten at link time with -ldflags "-X main.version=..."
var (
version = "dev"
commit = "unknown"
)

func main() {
fmt.Println("version:", version, "commit:", commit)

// VCS information can be read when it is embedded
if info, ok := debug.ReadBuildInfo(); ok {
for _, s := range info.Settings {
if s.Key == "vcs.revision" {
fmt.Println("vcs.revision:", s.Value)
}
}
}
}
VERSION=$(git describe --tags --always --dirty)
COMMIT=$(git rev-parse --short HEAD)

CGO_ENABLED=0 go build \
-trimpath \
-ldflags "-s -w -X main.version=${VERSION} -X main.commit=${COMMIT}" \
-o bin/app ./cmd/app

Example 2: Cross-compilation​

# List the available targets
go tool dist list

# Linux (amd64)
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o bin/app-linux-amd64 ./cmd/app

# Linux (arm64: Graviton, Docker on Apple Silicon, etc.)
GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -o bin/app-linux-arm64 ./cmd/app

# Windows (amd64)
GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -o bin/app-windows-amd64.exe ./cmd/app

# macOS (arm64)
GOOS=darwin GOARCH=arm64 CGO_ENABLED=0 go build -o bin/app-darwin-arm64 ./cmd/app

This corresponds to dotnet publish -r linux-x64 --self-contained in .NET, but needs no additional SDK or runtime pack installation.

Example 3: Makefile​

APP := app
PKG := ./cmd/app
VERSION ?= $(shell git describe --tags --always --dirty)
COMMIT ?= $(shell git rev-parse --short HEAD)
LDFLAGS := -s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT)

.PHONY: build test lint docker clean

build:
CGO_ENABLED=0 go build -trimpath -ldflags "$(LDFLAGS)" -o bin/$(APP) $(PKG)

test:
go test -race -cover ./...

lint:
golangci-lint run

docker:
docker build --build-arg VERSION=$(VERSION) --build-arg COMMIT=$(COMMIT) -t $(APP):$(VERSION) .

clean:
rm -rf bin

Makefile recipe lines must be indented with a tab.

Example 4: A multi-stage Dockerfile (distroless)​

# syntax=docker/dockerfile:1

# --- Build stage ---
FROM --platform=$BUILDPLATFORM golang:1.25 AS build
WORKDIR /src

# Fetch dependencies first to leverage the layer cache
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download

COPY . .

ARG TARGETOS
ARG TARGETARCH
ARG VERSION=dev
ARG COMMIT=unknown

RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
go build -trimpath \
-ldflags "-s -w -X main.version=${VERSION} -X main.commit=${COMMIT}" \
-o /out/app ./cmd/app

# --- Runtime stage ---
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/app /app
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/app"]
  • With --platform=$BUILDPLATFORM and TARGETOS / TARGETARCH, docker buildx build --platform linux/amd64,linux/arm64 can produce multi-architecture images while the build itself runs natively on the fast host architecture.
  • When using scratch, copy the CA certificates explicitly as follows.
FROM scratch
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=build /out/app /app
ENTRYPOINT ["/app"]

Adding .git, bin, local configuration files, and the like to .dockerignore makes the build context smaller.

The equivalent Dockerfile in C# builds with mcr.microsoft.com/dotnet/sdk and copies the output into mcr.microsoft.com/dotnet/aspnet. Because it includes the runtime, the runtime image is larger than Go's.

Example 5: Pushing to ACR and deploying to Azure Container Apps from GitHub Actions​

name: deploy

on:
push:
branches: [main]

permissions:
id-token: write # required for OIDC federated authentication
contents: read

env:
ACR_NAME: myregistry
IMAGE: myregistry.azurecr.io/app

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- run: go vet ./...
- run: go test -race -cover ./...

deploy:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

- name: Log in to ACR
run: az acr login --name ${{ env.ACR_NAME }}

- uses: docker/setup-buildx-action@v3

- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ env.IMAGE }}:${{ github.sha }}
build-args: |
VERSION=${{ github.sha }}
COMMIT=${{ github.sha }}

- name: Deploy to Azure Container Apps
run: |
az containerapp update \
--name my-app \
--resource-group my-rg \
--image ${{ env.IMAGE }}:${{ github.sha }}

Resource and secret names are examples. Change them to match your environment. For details on tests and lint, see Toolchain and Static Analysis.

5. Summary​

  • go build produces a single binary that needs no runtime by default. No per-configuration publish options are needed.
  • For production, the basic form is CGO_ENABLED=0 go build -trimpath -ldflags '-s -w -X main.version=...'. Embed the version into a string var with -X.
  • Cross-compilation needs only GOOS and GOARCH.
  • For Docker, use a multi-stage build that builds in a golang image and puts only the binary on distroless/static (or scratch). Copy go.mod first to leverage the dependency cache.
  • On scratch you must provide CA certificates and time zone data yourself. distroless has no shell, so leave health checks to the orchestrator.
  • From GitHub Actions, authenticate to Azure with OIDC, push to ACR with a commit-SHA tag, and deploy to Container Apps or similar.

References​