3 min read
On this page

Distribution & Cross-Compilation

Go compiles to a single static binary with no runtime dependencies. You can build for any platform from any platform with two environment variables. This makes distribution trivial: copy the binary, run it. No interpreter to install, no library versions to manage, no Docker required (though Docker works beautifully with Go too).

GOOS & GOARCH

Cross-compilation in Go requires no extra toolchains. Set GOOS and GOARCH:

# Build for Linux (amd64) from macOS
GOOS=linux GOARCH=amd64 go build -o myapp-linux-amd64 ./cmd/myapp

# Build for Windows
GOOS=windows GOARCH=amd64 go build -o myapp-windows-amd64.exe ./cmd/myapp

# Build for Linux ARM (Raspberry Pi, AWS Graviton)
GOOS=linux GOARCH=arm64 go build -o myapp-linux-arm64 ./cmd/myapp

# Build for macOS Apple Silicon
GOOS=darwin GOARCH=arm64 go build -o myapp-darwin-arm64 ./cmd/myapp

Common platform combinations:

GOOS      GOARCH    Description
------------------------------------------
linux     amd64     Most servers, CI/CD
linux     arm64     AWS Graviton, ARM servers
darwin    amd64     Intel Mac
darwin    arm64     Apple Silicon Mac
windows   amd64     Windows desktop/server

See all supported platforms:

go tool dist list

Go supports over 40 OS/architecture combinations. You can build for FreeBSD, OpenBSD, Plan 9, WebAssembly, and more.

Static Binaries

By default, Go produces statically linked binaries on most platforms. No shared libraries, no runtime dependencies:

go build -o myapp ./cmd/myapp
file myapp
# myapp: ELF 64-bit LSB executable, x86-64, statically linked
ldd myapp
# not a dynamic executable

If your code uses CGo (C bindings), the binary may link dynamically. Disable CGo for a fully static binary:

CGO_ENABLED=0 go build -o myapp ./cmd/myapp

This is important for Alpine Linux and scratch Docker images, which do not have glibc.

Embedding Version Information

Use -ldflags to embed build-time variables:

// main.go
package main

var (
    version = "dev"
    commit  = "none"
    date    = "unknown"
)

func main() {
    if len(os.Args) > 1 && os.Args[1] == "version" {
        fmt.Printf("myapp %s (commit %s, built %s)\n", version, commit, date)
        return
    }
    // ...
}

Set the values at build time:

go build -ldflags "-X main.version=1.2.3 -X main.commit=$(git rev-parse --short HEAD) -X main.date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" -o myapp ./cmd/myapp
$ myapp version
myapp 1.2.3 (commit a1b2c3d, built 2025-01-15T10:30:00Z)

goreleaser: Automated Releases

goreleaser automates the entire release process: cross-compilation, checksums, changelogs, and publishing to GitHub Releases:

# .goreleaser.yaml
version: 2

builds:
  - main: ./cmd/myapp
    binary: myapp
    env:
      - CGO_ENABLED=0
    goos:
      - linux
      - darwin
      - windows
    goarch:
      - amd64
      - arm64
    ldflags:
      - -s -w
      - -X main.version={{.Version}}
      - -X main.commit={{.Commit}}
      - -X main.date={{.Date}}

archives:
  - format: tar.gz
    name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}"
    format_overrides:
      - goos: windows
        format: zip

checksum:
  name_template: "checksums.txt"

changelog:
  sort: asc
  filters:
    exclude:
      - "^docs:"
      - "^test:"

Release with a git tag:

git tag v1.2.3
git push origin v1.2.3
goreleaser release

In CI (GitHub Actions):

# .github/workflows/release.yaml
name: Release
on:
  push:
    tags:
      - "v*"

jobs:
  release:
    runs-on: ubuntu-latest
    permissions:
      contents: write
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: actions/setup-go@v5
        with:
          go-version: stable
      - uses: goreleaser/goreleaser-action@v6
        with:
          args: release --clean
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

Docker: scratch Base Image

Go's static binaries are perfect for Docker. The binary IS the container:

# Build stage
FROM golang:1.23-alpine AS build

WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download

COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /myapp ./cmd/myapp

# Runtime stage
FROM scratch

COPY --from=build /myapp /myapp
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/

EXPOSE 8080
ENTRYPOINT ["/myapp"]

The scratch image is literally empty: no shell, no OS, no package manager. Your final image contains only the binary and CA certificates (needed for HTTPS).

Image             Size
----------------------------
golang:1.23       800MB
ubuntu:24.04      80MB
alpine:3.19       7MB
scratch + Go bin  ~10-15MB

If you need a shell for debugging, use alpine instead of scratch:

FROM alpine:3.19
RUN apk --no-cache add ca-certificates
COPY --from=build /myapp /myapp
ENTRYPOINT ["/myapp"]

Reduce Binary Size

The -ldflags="-s -w" flags strip debug information and symbol tables:

# Without stripping
go build -o myapp ./cmd/myapp          # ~15MB

# With stripping
go build -ldflags="-s -w" -o myapp ./cmd/myapp  # ~10MB

For even smaller binaries, use upx compression (trades startup time for size).

Distributing via go install

If your tool is open source, users can install it directly:

go install github.com/yourname/myapp@latest

This downloads the source, compiles it for the user's platform, and puts the binary in $GOPATH/bin. It is the standard way to distribute Go CLI tools.

For this to work, your module must be tagged with semantic versions:

git tag v1.0.0
git push origin v1.0.0

Users can install specific versions:

go install github.com/yourname/myapp@v1.2.3

Build Scripts

For projects with multiple binaries or complex build steps, use a Makefile:

VERSION := $(shell git describe --tags --always --dirty)
COMMIT  := $(shell git rev-parse --short HEAD)
DATE    := $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
LDFLAGS := -s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(DATE)

.PHONY: build
build:
	CGO_ENABLED=0 go build -ldflags="$(LDFLAGS)" -o bin/myapp ./cmd/myapp

.PHONY: build-all
build-all:
	GOOS=linux   GOARCH=amd64 go build -ldflags="$(LDFLAGS)" -o bin/myapp-linux-amd64 ./cmd/myapp
	GOOS=linux   GOARCH=arm64 go build -ldflags="$(LDFLAGS)" -o bin/myapp-linux-arm64 ./cmd/myapp
	GOOS=darwin  GOARCH=arm64 go build -ldflags="$(LDFLAGS)" -o bin/myapp-darwin-arm64 ./cmd/myapp
	GOOS=windows GOARCH=amd64 go build -ldflags="$(LDFLAGS)" -o bin/myapp-windows-amd64.exe ./cmd/myapp

.PHONY: docker
docker:
	docker build -t myapp:$(VERSION) .

Common Pitfalls

  • Forgetting CGO_ENABLED=0 for Docker scratch images. If any dependency uses CGo, the binary links dynamically and fails on scratch/alpine. Always set CGO_ENABLED=0.
  • Not embedding version info. Users need myapp version to report bugs. Always embed version, commit, and build date.
  • Shipping debug binaries in production. Use -ldflags="-s -w" to strip debug info. The binary is 30-40% smaller.
  • Not including CA certificates in scratch images. Without /etc/ssl/certs/ca-certificates.crt, your binary cannot make HTTPS requests.
  • Building for the wrong architecture. ARM servers (Graviton, M-series) need GOARCH=arm64, not amd64. CI defaults are usually amd64.
  • Not tagging releases for go install. Without semantic version tags, go install @latest does not work.

Key Takeaways

  • Cross-compilation requires only GOOS and GOARCH. No extra toolchains needed.
  • CGO_ENABLED=0 ensures a fully static binary with no system dependencies.
  • Use -ldflags to embed version, commit, and build date at compile time.
  • goreleaser automates cross-compilation, checksums, and GitHub Releases.
  • Docker scratch images with a Go binary produce containers under 15MB.
  • go install is the standard distribution method for open-source Go CLI tools.
  • Cross-compilation to any platform from any platform is one of Go's most practical advantages.