Building, cross compiling and shipping one binary
This is the chapter that explains why people who deploy Node sound envious when Go comes up.
This is the chapter that explains why people who deploy Node sound envious when Go comes up. Deployment is scp and ./app, and the rest of this chapter is the detail around that sentence.
The build
go build -o app ../app
One file. It contains your code, every dependency, and the Go runtime. The target machine needs nothing: no Go, no package manager, no shared libraries.
If you know JavaScript
Deploying a Next.js app means Node of the right major version, anode_modules tree, a build step, and a process manager. Deploying this means copying one file. That is the whole difference, and it is the reason Go dominates CLI tools and containers.
Smaller binaries
The default build includes debug symbols and a symbol table.
go build -ldflags="-s -w" -o app .
-s drops the symbol table, -w the DWARF debug information. Expect roughly 25 to 30 percent off. The cost is that a stack trace from a panic loses some detail and the binary cannot be inspected with a debugger, so many teams ship unstripped and accept the size.
For genuinely small, upx compresses further, at the cost of slower startup and antivirus software treating your binary with suspicion. Rarely worth it.
Embedding the version
A binary that cannot tell you which commit it is costs real time during an incident.
package mainvar (version = "dev"commit = "none"date = "unknown")func main() {if len(os.Args) > 1 && os.Args[1] == "version" {fmt.Printf("%s (%s) built %s\n", version, commit, date)return}...}
go build -ldflags="\-X main.version=1.4.2 \-X main.commit=$(git rev-parse --short HEAD) \-X main.date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" -o app .
-X sets a string variable at link time. It only works on string variables in the main package or a named package path, and only ones that are not constants.
If you know JavaScript
Grit does exactly this, which is why grit version reports a real version rather than "dev". Its release process bumps three places that must agree: the Go variable, the scaffolder's default, and the documentation site's constant. Chapter 47 covers why a version that disagrees with itself is worse than no version.
Cross compiling
Two environment variables, no toolchain to install.
GOOS=linux GOARCH=amd64 go build -o app-linux-amd64 .GOOS=linux GOARCH=arm64 go build -o app-linux-arm64 .GOOS=darwin GOARCH=arm64 go build -o app-darwin-arm64 .GOOS=windows GOARCH=amd64 go build -o app-windows-amd64.exe .
From any machine, to any target. This is not an exaggeration: a Windows laptop builds the Linux ARM binary that runs on your Raspberry Pi, with no container and no configuration.
go tool dist list # every supported pair
Watch out
Cross compilation is free until you use cgo. Any package binding to a C library (the classic mattn/go-sqlite3, some image codecs, some crypto) needs a C cross-compiler for the target, and the simplicity evaporates.
The defence is to prefer pure Go packages. This is why the database chapter used modernc.org/sqlite rather than mattn/go-sqlite3: the former is Go all the way down, so GOOS=linux go build works from anywhere. Check for cgo with:
CGO_ENABLED=0 go build ./...
If that fails, something in your tree needs C.
Containers, in two stages
A multi-stage Dockerfile builds in one image and ships in a tiny one.
# BuildFROM golang:1.25-alpine AS buildWORKDIR /src# Copy manifests first: this layer caches unless dependencies change.COPY go.mod go.sum ./RUN go mod downloadCOPY . .RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /bin/app .# ShipFROM gcr.io/distroless/static-debian12COPY --from=build /bin/app /appUSER nonroot:nonrootENTRYPOINT ["/app"]
The final image contains your binary, CA certificates and nothing else: no shell, no package manager, no libc. Typical size is your binary plus about two megabytes.
CGO_ENABLED=0 is required for the static base, because there is no libc in it to dynamically link against.
Watch out
Copy go.mod and go.sum and run go mod download before copying your source. Docker caches layers, and if the source is copied first then every code change re-downloads every dependency. This one ordering decision is the difference between a ten second rebuild and a two minute one.
Embedding files into the binary
go:embed puts files inside the executable, so your single-file deployment can still serve a frontend.
import "embed"//go:embed static/*var staticFiles embed.FS//go:embed schema.sqlvar schema stringfunc main() {http.Handle("/static/", http.FileServerFS(staticFiles))db.Exec(schema)}
The comment is the directive and must sit immediately above the variable with no blank line. embed.FS for a tree, string or []byte for one file.
This is how a Go program ships a built React app inside itself: compile the frontend, embed dist/, and deploy one file that serves both the API and the UI. Grit's single-binary architecture mode does precisely this.
Production flags worth knowing
go build -trimpath -ldflags="-s -w" -o app .
-trimpath removes absolute file paths from the binary, so a panic trace saysmain.go:42 rather than /home/you/projects/secret-name/main.go:42. It also makes builds reproducible, which matters if you sign releases.
GOAMD64=v3 go build -o app .
Targets a newer x86 instruction set for a few percent. Only do this if you control the hardware, because the binary will not run on older CPUs.
Automating releases
A tag-triggered workflow that cross compiles and attaches binaries:
name: Releaseon:push:tags: ['v*']jobs:release:runs-on: ubuntu-latestpermissions:contents: writesteps:- uses: actions/checkout@v4with: { fetch-depth: 0 }- uses: actions/setup-go@v5with: { go-version: '1.25' }- run: go test -race ./...- uses: goreleaser/goreleaser-action@v6with: { args: release --clean }env:GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
goreleaser reads a config file, builds every platform, writes checksums, generates release notes from commits and uploads everything. It is the standard tool, and it is what turns "I tagged v1.2.0" into binaries for six platforms without further thought.
Project 23: ship the shortener
Take the URL shortener and make it genuinely deployable: versioned, containerised and cross compiled.
First, the version:
// version.gopackage mainimport ("fmt""runtime")var (version = "dev"commit = "none"date = "unknown")func versionString() string {return fmt.Sprintf("shortener %s (%s) built %s with %s",version, commit, date, runtime.Version())}
Wire it into main before anything else happens:
if len(os.Args) > 1 && os.Args[1] == "version" {fmt.Println(versionString())return}
A Makefile, because the build command is now too long to retype:
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 test release cleanbuild:go build -trimpath -ldflags="$(LDFLAGS)" -o bin/shortener .test:go test -race -cover ./...release:GOOS=linux GOARCH=amd64 go build -trimpath \-ldflags="$(LDFLAGS)" -o bin/shortener-linux-amd64 .GOOS=linux GOARCH=arm64 go build -trimpath \-ldflags="$(LDFLAGS)" -o bin/shortener-linux-arm64 .GOOS=darwin GOARCH=arm64 go build -trimpath \-ldflags="$(LDFLAGS)" -o bin/shortener-darwin-arm64 .GOOS=windows GOARCH=amd64 go build -trimpath \-ldflags="$(LDFLAGS)" -o bin/shortener-windows-amd64.exe .clean:rm -rf bin
The Dockerfile:
FROM golang:1.25-alpine AS buildWORKDIR /srcCOPY go.mod go.sum ./RUN go mod downloadCOPY . .ARG VERSION=dockerRUN CGO_ENABLED=0 go build -trimpath \-ldflags="-s -w -X main.version=$VERSION" -o /bin/app .FROM gcr.io/distroless/static-debian12COPY --from=build /bin/app /appUSER nonroot:nonrootEXPOSE 8080ENV DB_PATH=/data/links.dbENTRYPOINT ["/app"]
Build and check the results:
make build && ./bin/shortener versionmake release && ls -lh bin/docker build --build-arg VERSION=1.0.0 -t shortener .docker images shortener # a few megabytes, not a few hundreddocker run -p 8080:8080 -v $(pwd)/data:/data shortener
Three things to notice. make release produces four binaries for four platforms from one machine, in seconds, with no cross-compilation toolchain, because nothing in this project uses cgo. The container has no shell at all, so docker exec -it ... sh fails: that is the point of distroless, and it is worth experiencing once so the tradeoff is a decision rather than a surprise. And the whole image is small enough that pulling it is not a deployment consideration.
Compare the image size to a Node service doing the same job. That number is the argument of this chapter.
Your turn
Add go:embed for a small HTML page at / that posts to /shorten, so the single binary serves a usable interface as well as an API. Then add a/healthz that reports the version, and confirm with docker run that the version baked in by the build argument is what the endpoint reports. That round trip, from build flag to running container to HTTP response, is the whole deployment story in one check.
Assignment
Commit your work with the message Shipping - Assignment One, exactly as written, so the leaderboard can score it.
Try it
Ship the shortener as if somebody else had to run it.
One. Build with the version embedded, and have -version print it:
go build -ldflags "-X main.version=$(git describe --always)" .
Two. Cross compile for Linux, macOS and Windows, and record the three sizes in RELEASE.md.
Three. A two-stage Dockerfile ending in a minimal image. Record the image size next to the binary sizes.
Four. Embed the static files with embed, so one file is genuinely the whole program.
Five. A GitHub Actions workflow that does all of it on a tag and attaches the binaries to the release. Push a tag and let it run. That workflow file, in your repository, is the assignment.
Spot a typo? Have an idea?
Help us improve this lesson. One click opens a GitHub issue with the lesson URL pre-filled, suggest clearer wording, report a bug, or request more depth. The course keeps improving thanks to learners like you.
Suggest an improvement on GitHub