Single Binary Deploys: The Killer Feature

Here’s the thing that sold me on Go for production: you build a binary, you copy it to a server, you run it. No runtime to install. No framework version matching. No dependency hell. Just a file.

If you’ve ever dealt with .NET runtime versioning on servers, or debugged why dotnet can’t find the right SDK, or explained to ops why they need to install framework X before deploying your app—Go’s deployment model will feel like a revelation.

The .NET Deployment Model

In .NET, you have choices:

Framework-dependent (FDD):

dotnet publish -c Release
# Produces DLLs, needs .NET runtime on target

Small output, but requires runtime installation and version matching.

Self-contained (SCD):

dotnet publish -c Release --self-contained -r linux-x64
# Produces executable + runtime + dependencies
# ~70MB+ for a simple API

Everything included, but large.

Single-file:

dotnet publish -c Release --self-contained -r linux-x64 -p:PublishSingleFile=true
# Extracts to temp on first run

One file, but it’s a bundle that extracts itself.

Native AOT (.NET 7+):

dotnet publish -c Release -r linux-x64 -p:PublishAot=true
# True native binary, ~10-30MB

Finally a real binary, but limited reflection support.

The Go Model: Just Build

go build -o myapp ./cmd/server

That’s it. myapp is a statically-linked binary. Copy it anywhere with the same OS/architecture and run it.

scp myapp server:/usr/local/bin/
ssh server '/usr/local/bin/myapp'

No runtime. No dependencies. No extraction. No installation.

Cross-Compilation

This is where Go shines. Build for any platform from any platform:

# From macOS, build for Linux
GOOS=linux GOARCH=amd64 go build -o myapp-linux

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

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

No Docker, no VMs, no cross-compilers to install. Set environment variables, build.

All Platforms

Common targets:

GOOSGOARCHTarget
linuxamd64Standard Linux servers
linuxarm64AWS Graviton, ARM servers
darwinamd64Intel Macs
darwinarm64Apple Silicon Macs
windowsamd64Windows servers
# Build for all common targets
for os in linux darwin windows; do
  for arch in amd64 arm64; do
    GOOS=$os GOARCH=$arch go build -o myapp-$os-$arch ./cmd/server
  done
done

Static Linking by Default

Go binaries are statically linked by default. No shared library dependencies:

ldd myapp
# not a dynamic executable

Compare to a typical C program:

ldd /usr/bin/ls
# linux-vdso.so.1
# libselinux.so.1
# libc.so.6
# libpcre2-8.so.0
# ...

Static linking means:

  • No “works on my machine” due to library versions
  • Deploy to minimal containers (scratch, distroless)
  • No glibc compatibility concerns

CGO and Dynamic Linking

The exception is CGO—Go code that calls C libraries:

// #include <sqlite3.h>
import "C"

CGO produces dynamically-linked binaries. Disable it if you don’t need it:

CGO_ENABLED=0 go build -o myapp

Most pure-Go programs don’t need CGO.

Binary Size

A simple Go HTTP server: ~10-15MB A typical Go CLI tool: ~5-10MB A complex service with many dependencies: ~20-30MB

Compare to .NET self-contained: 70-150MB Compare to .NET Native AOT: 10-30MB

Go binaries are competitive with .NET AOT and much smaller than self-contained.

Build Reproducibility

Go builds are reproducible by default. Same inputs, same binary:

go build -o app1 ./cmd/server
go build -o app2 ./cmd/server
sha256sum app1 app2
# Same hash (usually)

For guaranteed reproducibility:

go build -trimpath -ldflags="-s -w" -o myapp
  • -trimpath: removes file paths from binary
  • -s -w: strips debug info and DWARF

Deployment Patterns

Direct Binary Deployment

# Build
go build -o myapp ./cmd/server

# Deploy
scp myapp server:/opt/myapp/
ssh server 'systemctl restart myapp'

Systemd unit file:

[Unit]
Description=My App
After=network.target

[Service]
Type=simple
ExecStart=/opt/myapp/myapp
Restart=always
Environment=PORT=8080

[Install]
WantedBy=multi-user.target

Tarball Distribution

# Build and package
go build -o myapp ./cmd/server
tar -czvf myapp-linux-amd64.tar.gz myapp config.yaml

# Distribute
curl -L https://releases.example.com/myapp-linux-amd64.tar.gz | tar xz
./myapp

Container (Preview for Later)

FROM scratch
COPY myapp /myapp
ENTRYPOINT ["/myapp"]

A container with just your binary. We’ll cover this properly in the Dockerfile post.

The Comparison

Aspect.NET SCD.NET AOTGo
Binary size70-150MB10-30MB10-20MB
Cross-compilationLimitedLimitedTrivial
Runtime requiredNoNoNo
Reflection supportFullLimitedFull
CGO equivalentP/InvokeP/InvokeCGO
Build timeModerateSlowFast
Static linkingOptionalDefaultDefault

The Honest Take

Single binary deployment is Go’s killer feature for operations.

What Go does better:

  • Trivial cross-compilation
  • Fast builds
  • Small binaries by default
  • No runtime installation
  • Static linking by default

What .NET does better:

  • .NET AOT is catching up
  • Better Windows integration
  • Richer ecosystem for some domains
  • Native AOT on more platforms (eventually)

The verdict: If deployment simplicity matters—and it should—Go wins. One binary, one target, done.

Your ops team will thank you. Your deployment scripts will be three lines. Your containers will be tiny. Your CI/CD will be simple.

This is the feature that keeps Go relevant in a world of VMs and containers. Simplicity scales.


Next up: shrinking binaries and build tags—ldflags, conditional compilation, and getting even leaner.