Building and Releasing Software

Updated

July 30, 2026

Overview

Go compiles to static binaries, making deployment simple. This chapter covers building, cross-compilation, and release workflows.

Basic Build

go build -o myapp ./cmd/server

Production Build

go build \
    -ldflags="-s -w" \
    -o bin/myapp \
    ./cmd/server

Flags: - -s - Strip symbol table - -w - Strip DWARF debugging info

Version Injection

var (
    version = "dev"
    commit  = "none"
    date    = "unknown"
)
go build -ldflags="-X main.version=1.0.0 -X main.commit=$(git rev-parse HEAD)"

Cross-Compilation

# Linux
GOOS=linux GOARCH=amd64 go build -o app-linux

# Windows
GOOS=windows GOARCH=amd64 go build -o app.exe

# macOS ARM
GOOS=darwin GOARCH=arm64 go build -o app-darwin

# All platforms
for os in linux darwin windows; do
    for arch in amd64 arm64; do
        GOOS=$os GOARCH=$arch go build -o bin/app-$os-$arch
    done
done

Docker

# Multi-stage build
FROM golang:1.26-alpine AS builder
WORKDIR /app
COPY . .
RUN go build -o /app/server ./cmd/server

FROM alpine:latest
COPY --from=builder /app/server /server
ENTRYPOINT ["/server"]

GitHub Actions

name: Release
on:
  push:
    tags: ['v*']

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with:
          go-version: '1.26'
      - run: go build -o app
      - uses: softprops/action-gh-release@v1
        with:
          files: app

GoReleaser

# .goreleaser.yaml
builds:
  - main: ./cmd/server
    goos: [linux, darwin, windows]
    goarch: [amd64, arm64]

archives:
  - format: tar.gz
goreleaser release

Summary

Task Command
Build go build -o app
Optimize -ldflags="-s -w"
Cross-compile GOOS=linux GOARCH=amd64
Version inject -ldflags="-X main.version=x"

More examples

Version and commit via ldflags

mkdir -p /tmp/go-ldflags && cd /tmp/go-ldflags
go mod init example.com/ldflags

Save as main.go:

package main

import "fmt"

// Overridden at link time:
// go run -ldflags="-X main.version=1.2.3 -X main.commit=abc1234" .
var (
    version = "dev"
    commit  = "none"
)

func main() {
    fmt.Printf("version=%s commit=%s\n", version, commit)
}
go run .
go run -ldflags="-X main.version=1.2.3 -X main.commit=abc1234" .

Expected output:

version=dev commit=none
version=1.2.3 commit=abc1234

Build info from the binary itself

mkdir -p /tmp/go-buildinfo && cd /tmp/go-buildinfo
go mod init example.com/buildinfo

Save as main.go:

package main

import (
    "fmt"
    "runtime/debug"
)

func main() {
    bi, ok := debug.ReadBuildInfo()
    if !ok {
        fmt.Println("no build info")
        return
    }
    fmt.Println("path:", bi.Path)
    fmt.Println("go:", bi.GoVersion)
    for _, s := range bi.Settings {
        switch s.Key {
        case "GOOS", "GOARCH", "-compiler", "CGO_ENABLED", "vcs.revision", "vcs.modified":
            fmt.Printf("%s=%s\n", s.Key, s.Value)
        }
    }
}
go run .

Expected output (keys vary by toolchain/VCS):

path: example.com/buildinfo
go: go1.22.x
GOOS=...
GOARCH=...
-compiler=gc
CGO_ENABLED=...

Runnable example

Version injection and strip flags are the core of Go release builds. This program prints embedded metadata and reports binary-oriented build info.

mkdir -p /tmp/go-release-demo && cd /tmp/go-release-demo
go mod init example.com/release-demo

Save as main.go:

package main

import (
    "fmt"
    "runtime"
    "runtime/debug"
)

// Populated via: -ldflags="-X main.version=... -X main.commit=... -X main.date=..."
var (
    version = "dev"
    commit  = "none"
    date    = "unknown"
)

func main() {
    fmt.Printf("version=%s commit=%s date=%s\n", version, commit, date)
    fmt.Printf("runtime=%s %s/%s\n", runtime.Version(), runtime.GOOS, runtime.GOARCH)

    if info, ok := debug.ReadBuildInfo(); ok {
        fmt.Println("module:", info.Path)
        for _, s := range info.Settings {
            switch s.Key {
            case "-tags", "CGO_ENABLED", "GOOS", "GOARCH", "vcs.revision", "vcs.modified":
                fmt.Printf("build %s=%s\n", s.Key, s.Value)
            }
        }
    }
}
go run -ldflags="-X main.version=1.0.0 -X main.commit=abc1234 -X main.date=2026-07-28" .

CGO_ENABLED=0 go build -trimpath -ldflags="-s -w \
  -X main.version=1.0.0 \
  -X main.commit=abc1234 \
  -X main.date=2026-07-28" -o app .

./app

# Cross-compile matrix sample:
for pair in linux/amd64 linux/arm64 darwin/arm64 windows/amd64; do
  GOOS=${pair%/*} GOARCH=${pair#*/}
  ext=""; [ "$GOOS" = windows ] && ext=".exe"
  CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X main.version=1.0.0" \
    -o "app-${GOOS}-${GOARCH}${ext}" .
  echo "built app-${GOOS}-${GOARCH}${ext}"
done

Expected output (illustrative):

version=1.0.0 commit=abc1234 date=2026-07-28
runtime=go1.22.x darwin/arm64
module: example.com/release-demo
build GOOS=darwin
build GOARCH=arm64
build CGO_ENABLED=0

What to notice

  • -X main.version=... rewrites string vars at link time—no runtime git dependency in production.
  • -s -w strips symbol/DWARF data; pair with -trimpath for more reproducible artifacts.
  • debug.ReadBuildInfo exposes module and VCS metadata embedded by modern Go toolchains.

Try next

  • Compare ls -la sizes of stripped vs unstripped builds.
  • Add a -X main.commit=$(git rev-parse --short HEAD) one-liner to a release script.