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.
| Flag | Meaning |
|---|---|
-o path | Specify the output file name or directory |
-trimpath | Remove 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 name | Enable build tags (switches //go:build constraints) |
-race | Enable the data race detector (mainly for tests; normally not used for production builds) |
-buildvcs=false | Disable embedding version control information |
Main uses of -ldflags:
-somits the symbol table and-womits DWARF debug information. The binary gets smaller, but investigating it with a debugger becomes harder.-X import.path.variable=valuesets the value of a string variable at link time. It is used to embed a version or commit hash. The target must be avar(not aconst) of string type, with no initializer or a constant string initializer. For a variable in themainpackage, writemain.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
netandos/userhave cgo-based implementations, so on Linux a build with cgo enabled may dynamically link libc. WithCGO_ENABLED=0they 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 withCGO_ENABLED=0. Either choose an implementation that doesn't use cgo (such asmodernc.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 image | Characteristics |
|---|---|
scratch | Completely empty. If you need CA certificates, time zone data, or /etc/passwd, you must COPY them yourself |
gcr.io/distroless/static-debian12 | A minimal image containing CA certificates, time zone data, and a non-root user. For static binaries |
gcr.io/distroless/base-debian12 | Includes glibc. For dynamically linked binaries that use cgo |
alpine | A 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
latestalone. - 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 specification | GOOS / GOARCH |
| mcr.microsoft.com/dotnet/aspnet | scratch / distroless / alpine |
Additional mappings that complement the table above:
| .NET (C#) | Go |
|---|---|
dotnet publish -c Release | go build (optimization is always on; there is no configuration distinction) |
-r linux-x64 --self-contained | GOOS=linux GOARCH=amd64 go build |
PublishSingleFile | A single binary by default |
PublishTrimmed / Native AOT | Not 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 build | The build cache (go env GOCACHE) works automatically |
#if DEBUG / conditional compilation | Build 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=$BUILDPLATFORMandTARGETOS/TARGETARCH,docker buildx build --platform linux/amd64,linux/arm64can 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 buildproduces 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 stringvarwith-X. - Cross-compilation needs only
GOOSandGOARCH. - For Docker, use a multi-stage build that builds in a
golangimage and puts only the binary ondistroless/static(orscratch). Copygo.modfirst to leverage the dependency cache. - On
scratchyou must provide CA certificates and time zone data yourself.distrolesshas 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
- Go Documentation
- A Tour of Go
- Effective Go
- cmd/go: Compile packages and dependencies
- cmd/link (ldflags)
- cmd/cgo
- runtime/debug.ReadBuildInfo
- Docker: Multi-stage builds
- GoogleContainerTools/distroless
- Deploy to Azure Container Apps with GitHub Actions
- Authenticate to Azure from GitHub Actions with OpenID Connect