Distributing C Artifacts for Go Modules: A Practical Approach
When building Go modules that depend on C/C++/Rust libraries via CGO, one of the biggest challenges is distribution. How do you ensure users can simply go get your module without needing complex build toolchains? This post explores our solution for distributing pre-compiled C artifacts for Go modules, using the Secure Low-Latency Interactive Messaging (SLIM) Go bindings as a case study.
The Challenge: CGO Dependencies
The SLIM Go bindings wrap a Rust library that provides secure messaging capabilities. While Go’s CGO makes it possible to call native libraries, it creates a distribution problem:
- Developers need a C compiler (gcc, clang, etc.)
- They need the native library already built for their platform
- Cross-compilation becomes painful
- Build times increase significantly
But there’s an even bigger challenge: end-user distribution. We wanted to ensure that applications built using our Go library could be distributed as single, self-contained binaries—without requiring end users to install additional native libraries on their systems.
This is a common pain point: you want the performance and safety of Rust/C++ with the simplicity of Go’s distribution model, while maintaining Go’s promise of “compile once, run anywhere” binaries.
Our Solution: Static Linking + GitHub Releases + Setup Tool
Our approach is built on a critical architectural decision: static linking. We distribute static library archives (.a files) rather than dynamic libraries (.so, .dylib, .dll). This means:
- At build time: Developers need the
.afile available - At runtime: End users need nothing—the native code is embedded in the Go binary
This preserves Go’s single-binary deployment model while leveraging native code performance.
We then developed a two-part solution for the build-time requirements:
- Pre-compiled static libraries hosted on GitHub Releases
- A lightweight setup tool developers run once
Architecture Overview
flowchart TD
classDef step fill:#0251af,stroke:#f3f6fd,stroke-width:2px,color:#f3f6fd;
A["User runs: go get github.com/agntcy/slim-bindings-go"]:::step --> B["User runs: go run .../cmd/slim-bindings-setup"]:::step
B --> C["Setup Tool Detects Platform<br>• OS: darwin/linux/windows<br>• Arch: amd64/arm64"]:::step
C --> D["Downloads from GitHub Release:<br>github.com/agntcy/slim<br>.../releases/download/...<br>slim-bindings-libs-v0.7.2/<br>slim-bindings-aarch64-apple-darwin.zip"]:::step
D --> E["Extracts to $GOPATH Cache Directory:<br>$GOPATH/.cgo-cache/slim-bindings/<br>libslim_bindings_aarch64_apple_darwin.a"]:::step
E --> F["CGO Flags Reference Cache Location:<br>#cgo darwin,arm64 LDFLAGS:<br>-L${SRCDIR}/../../../../.cgo-cache/slim-bindings<br>-lslim_bindings_aarch64_darwin"]:::step
Solution Details
1. Pre-compiled Static Libraries
The foundation of our approach is using static libraries (.a files). This is crucial for achieving our goal:
flowchart TD
classDef step fill:#0251af,stroke:#f3f6fd,stroke-width:2px,color:#f3f6fd;
Build["Build Time (Developer Machine)<br>✓ Needs: libslim_bindings_*.a (static library)<br>✓ Needs: Go compiler + CGO enabled<br>✓ Needs: C compiler (for CGO)<br>✓ Statically links SLIM code into Go binary"]:::step
Cmd["go build -o myapp"]:::step
Output["Output: myapp (single binary)<br>Contains:<br>• Go code<br>• SLIM native code (embedded from .a file)<br>• Links to standard system libraries only"]:::step
Deploy["Deployment (End User Machine)<br>✓ Only needs: myapp (single binary)<br>✓ Only needs: Standard OS libraries (glibc, etc.)<br>✗ Does NOT need: libslim_bindings_*.a<br>✗ Does NOT need: .so/.dylib/.dll files<br>✗ Does NOT need: SLIM installed separately<br>✗ Does NOT need: Any project-specific libraries"]:::step
Build --> Cmd --> Output --> Deploy
Why Static Linking?
- Self-contained binaries: All SLIM native code is embedded in the Go executable
- No additional runtime dependencies: End users don’t need to install additional libraries (beyond standard system libraries like glibc)
- Version consistency: No risk of library version mismatches at runtime
- Simplified deployment: True “compile once, deploy anywhere” for the target platform
The trade-off is larger binary sizes, but this is acceptable for most use cases and aligns with Go’s philosophy of self-contained binaries.
Note on Fully Static Binaries: On Linux, using the musl libc variant (e.g., x86_64-unknown-linux-musl) allows for fully statically compiled binaries with no dependency on glibc or any system libraries. This is ideal for minimal container images (like FROM scratch) or environments where you want absolute portability without any system library dependencies.
2. Library Distribution: Setup Tool Approach
Rather than embedding pre-compiled static libraries directly in the Go repository, we provide a lightweight setup tool that developers run once to prepare their build environment. Each static library is approximately ~150 MB in size (7 platforms × 150 MB ≈ 1 GB total), which presents several distribution challenges:
GitHub Repository Limitations:
- GitHub has a 100 MB file size limit without Git LFS, but
go getdoesn’t support Git LFS go gethas a hard-coded 500 MB size limit, preventing distribution even with workarounds
These technical constraints make it impossible to distribute the static libraries through the Git repository itself. Instead, our setup tool approach offers several advantages:
- Small repository size: Only source code is in the repo
- Flexible platform support: Add new platforms without repo bloat
- Version management: Libraries are downloaded for the specific version being used
- Developer control: Explicit setup step makes the native dependency transparent
- No Git/Go tooling limits: GitHub Releases can host large files that
go getcannot handle
The setup tool handles three key tasks: detecting the platform, determining where to cache libraries, and downloading the correct artifacts from GitHub Releases.
Platform Detection
The setup tool automatically detects the developer’s platform using Go’s runtime.GOOS and runtime.GOARCH values, then maps them to the appropriate Rust target triple (e.g., darwin/arm64 → aarch64-apple-darwin).
Developers can override the platform detection using standard Go environment variables:
GOOS: Target operating system (e.g.,linux,darwin,windows)GOARCH: Target architecture (e.g.,amd64,arm64)
This is useful for downloading libraries for a different platform than the one you’re currently on.
Cache Directory Strategy
We store the static libraries in $GOPATH/.cgo-cache/slim-bindings. This keeps our artifacts separate from Go’s protected module cache while still being relative to $GOPATH, which allows us to navigate safely from the module cache where the source is stored by go get when using the libraries as a downstream dependency.
Why $GOPATH/.cgo-cache Instead of pkg/mod?
- Write permissions: The
pkg/moddirectories are readonly and protected by lockfiles - Separation of concerns: Our CGO artifacts are separate from Go’s module cache
- Consistency: Go module cache is always relative to
$GOPATH - Non-home installations:
$GOPATHcan be set to any location (e.g.,/opt/go,/usr/local/go) - Build environment isolation: Works in Docker, CI/CD, and custom build environments
- Standard Go tooling: Uses
build.Default.GOPATHwhich respects Go’s default behavior
Cache Location:
- Path:
$GOPATH/.cgo-cache/slim-bindings/ - Example: If
GOPATH=/opt/go, libraries are in/opt/go/.cgo-cache/slim-bindings/
Download from GitHub Releases
The setup tool downloads pre-compiled libraries from GitHub Releases. Each release follows a consistent naming pattern:
- Release tag:
slim-bindings-libs-{version}(e.g.,slim-bindings-libs-v0.7.2) - Artifact naming:
slim-bindings-{target}.zip(e.g.,slim-bindings-aarch64-apple-darwin.zip) - Archive contents: Single static library file
libslim_bindings_{normalized_target}.a(~150 MB)
The setup tool constructs the download URL based on the detected platform and version, fetches the appropriate zip file, and extracts the static library to the cache directory.
3. CGO Linker Flags for Static Linking
The Go source file includes platform-specific CGO directives that reference the cache directory and ensure static linking:
/*
#cgo CFLAGS: -I${SRCDIR}
#cgo linux,amd64 LDFLAGS: -L${SRCDIR}/../../../../../.cgo-cache/slim-bindings -L${SRCDIR} -lslim_bindings_x86_64_linux_gnu -lm
#cgo linux,arm64 LDFLAGS: -L${SRCDIR}/../../../../../.cgo-cache/slim-bindings -L${SRCDIR} -lslim_bindings_aarch64_linux_gnu -lm
#cgo darwin,amd64 LDFLAGS: -L${SRCDIR}/../../../../../.cgo-cache/slim-bindings -L${SRCDIR} -lslim_bindings_x86_64_darwin -Wl,-undefined,dynamic_lookup
#cgo darwin,arm64 LDFLAGS: -L${SRCDIR}/../../../../../.cgo-cache/slim-bindings -L${SRCDIR} -lslim_bindings_aarch64_darwin -Wl,-undefined,dynamic_lookup
#cgo windows,amd64 LDFLAGS: -L${SRCDIR}/../../../../../.cgo-cache/slim-bindings -L${SRCDIR} -lslim_bindings_x86_64_windows_gnu -lws2_32 -lbcrypt -ladvapi32 -luserenv -lntdll -lgcc_eh -lgcc -lkernel32 -lole32
#include <slim_bindings.h>
*/
import "C"
Key Points:
${SRCDIR}is a CGO variable that points to the directory containing the Go source file- We use relative paths to traverse up to
$GOPATH, then down to.cgo-cache/slim-bindings - Platform-specific flags ensure the correct library variant is linked
- The
-lflag links against.afiles (static archives), not dynamic libraries - The linker automatically embeds the static library code into the final Go binary
- Additional system libraries (
-lm,-lws2_32, etc.) are included as needed for runtime dependencies
Static Linking in Action:
When you run go build, CGO:
- Finds
libslim_bindings_*.ain the$GOPATH/.cgo-cache/slim-bindings/directory - Extracts all object files from the static archive
- Links them directly into your Go binary
- Results in a single executable with no external native library dependencies
Our Implementation for SLIM
The release process is split into two phases: building the native libraries in the SLIM repository, and generating/distributing the Go bindings in the slim-bindings-go repository.
Phase 1: Native Library Build (SLIM Repository)
The CI/CD pipeline in the main SLIM repository handles cross-compilation of the Rust library:
- Cross-compile Rust library for all target platforms using
cargo zigbuild- Targets:
x86_64-unknown-linux-gnu,aarch64-unknown-linux-gnu,x86_64-apple-darwin, etc. - Produces static library archives (
.afiles) - We use Zig instead of traditional cross-compilation toolchains because Zig provides true cross-compilation capabilities for C dependencies without needing separate toolchains for every architecture
- Targets:
- Package per platform into zip files
- Each platform gets its own zip:
slim-bindings-{target}.zip - Contains the static library:
libslim_bindings_{normalized_target}.a
- Each platform gets its own zip:
- Upload to GitHub Release with version tag
- Release tag:
slim-bindings-libs-v0.7.2 - All platform zips attached to the release
- Release tag:
Supported Platforms:
We build static libraries for 7 platform combinations:
| OS | Architecture | Target Triple | Library File | Notes |
|---|---|---|---|---|
| Linux | amd64 | x86_64-unknown-linux-gnu | libslim_bindings_x86_64_linux_gnu.a | Requires glibc at runtime |
| Linux | arm64 | aarch64-unknown-linux-gnu | libslim_bindings_aarch64_linux_gnu.a | Requires glibc at runtime |
| Linux | amd64 (musl) | x86_64-unknown-linux-musl | libslim_bindings_x86_64_linux_musl.a | Fully static, no glibc needed |
| Linux | arm64 (musl) | aarch64-unknown-linux-musl | libslim_bindings_aarch64_linux_musl.a | Fully static, no glibc needed |
| macOS | amd64 | x86_64-apple-darwin | libslim_bindings_x86_64_apple_darwin.a | |
| macOS | arm64 | aarch64-apple-darwin | libslim_bindings_aarch64_apple_darwin.a | |
| Windows | amd64 | x86_64-pc-windows-gnu | libslim_bindings_x86_64_windows_gnu.a |
The musl variants are particularly useful for:
- Minimal Docker images: Deploy to
FROM scratchor minimal base images - Portable binaries: No system library dependencies beyond the kernel
- Legacy systems: Run on systems with different or missing glibc versions
Example release in the SLIM repository:
Release: slim-bindings-libs-v0.7.2
Generated from https://github.com/agntcy/slim/commit/a51521ea
Assets:
- slim-bindings-x86_64-unknown-linux-gnu.zip
- slim-bindings-aarch64-unknown-linux-gnu.zip
- slim-bindings-x86_64-unknown-linux-musl.zip
- slim-bindings-aarch64-unknown-linux-musl.zip
- slim-bindings-aarch64-apple-darwin.zip
- slim-bindings-x86_64-apple-darwin.zip
- slim-bindings-x86_64-pc-windows-gnu.zip
Phase 2: Go Bindings Generation (slim-bindings-go Repository)
After the native libraries are built, the Go bindings are generated and published:
- Generate Go bindings using UniFFI
- UniFFI reads the Rust library and generates Go code
- Produces:
slim_bindings.go(Go wrapper code) - Produces:
slim_bindings.h(C header file)
- Copy artifacts to distribution repo
- Generated Go code →
github.com/agntcy/slim-bindings-go/slim_bindings.go - Header file →
github.com/agntcy/slim-bindings-go/slim_bindings.h - Setup tool →
github.com/agntcy/slim-bindings-go/cmd/slim-bindings-setup/
- Generated Go code →
- Cut matching version tag
- Tag in slim-bindings-go:
v0.7.2(matches the library version) - Go module version:
github.com/agntcy/slim-bindings-go@v0.7.2
- Tag in slim-bindings-go:
We use this two-repository approach because Go uses code repositories for distribution via go get. The main SLIM repository is a Rust project with its own structure and dependencies—it doesn’t make sense to use it as a Go module distribution point. By maintaining a separate slim-bindings-go repository, we provide a clean Go module that developers can import without pulling in the entire SLIM codebase.
Final User Experience
From a developer’s perspective, the workflow is simple:
# 1. Install the module
go get github.com/agntcy/slim-bindings-go
# 2. Run the setup tool (one-time, downloads static library)
go run github.com/agntcy/slim-bindings-go/cmd/slim-bindings-setup
The setup tool will output:
╔═══════════════════════════════════════════════════════════╗
║ SLIM Bindings Setup ║
╚═══════════════════════════════════════════════════════════╝
Version: v0.7.2
Platform: darwin/arm64
Target: aarch64-apple-darwin
📦 Downloading SLIM bindings library...
Version: v0.7.2
Platform: aarch64-apple-darwin
URL: https://github.com/agntcy/slim/releases/download/...
Extracted: libslim_bindings_aarch64_apple_darwin.a (685 MB)
✅ Library installed to: $GOPATH/.cgo-cache/slim-bindings
✅ Setup complete! You can now build Go projects using SLIM bindings.
Then continue with your build:
# 3. Build your application (native code gets statically linked)
go build -o myapp
# 4. Deploy the single binary (no additional runtime dependencies!)
./myapp
Cross-Compiling CGO Applications: If you need to cross-compile your Go application with CGO enabled for different platforms, you can use Zig as a drop-in C compiler replacement. This eliminates the need for platform-specific toolchains:
# Example: Cross-compile for Linux ARM64 from macOS
CGO_ENABLED=1 GOOS=linux GOARCH=arm64 CC="zig cc -target aarch64-linux-gnu" go build -o myapp
Zig provides true cross-compilation for CGO without maintaining separate toolchains for each target platform.
From an end user’s perspective, it’s even simpler:
# Just run the application - no installation of native libraries required!
./myapp
Advantages of This Approach
- No Additional Runtime Dependencies: End users don’t need to install additional native libraries—binaries are self-contained (standard system libraries like glibc are still required)
- No Build Toolchain Required for Developers: Developers don’t need Rust, C compilers (beyond what CGO needs), or complex build dependencies
- Fast Installation: Download pre-compiled static libraries instead of compiling from source
- Version Pinning: Go modules naturally version-pin the setup tool and library version together
- Cross-Platform: Works consistently across Linux, macOS, and Windows
- Transparent: Developers can see exactly what’s being downloaded and where it’s stored
- Single Binary Deployment: Maintains Go’s promise of simple, single-binary deployment
Limitations and Trade-offs
- Manual Setup Step: Developers must run the setup tool once (not fully automatic)
- Storage Overhead: Each platform variant is ~150 MB in the developer’s cache
- Binary Size: Static linking increases final binary size (typically several MB when stripped)
- Platform Coverage: Need to pre-build static libraries for all target platforms
- Musl vs GNU libc: Linux developers need to pick the right variant (though we auto-detect this)
- Trust Model: Developers trust our GitHub Release artifacts
- CGO Required: Developers must have CGO enabled (which requires a C compiler at build time)
Alternative Approaches We Considered
1. Dynamic Libraries (.so/.dylib/.dll)
Pros: Smaller binaries, can share libraries across applications Cons: End users must install native libraries—this was unacceptable for our use case. Breaks Go’s single-binary deployment model.
2. Embed Libraries in Git Repository
Pros: Fully automatic, works seamlessly with go get, no extra setup step
Cons: Each library is ~150 MB (~1 GB total for 7 platforms). GitHub’s 100 MB limit requires Git LFS, but go get doesn’t support Git LFS and has a 500 MB hard limit.
3. Build from Source
Pros: Maximum flexibility, no trust issues, smallest approach Cons: Requires Rust toolchain on developer machines, very slow builds, potential compilation failures, poor developer experience
4. Separate Binary Distribution
Pros: Can use OS package managers Cons: Breaks Go’s module system, complex installation instructions for developers, still requires end users to install packages
5. Go Generate with Download Script
Pros: Can be automatic Cons: Security concerns with running arbitrary scripts, harder to audit
Conclusion
Distributing C artifacts for Go modules requires balancing simplicity, security, and deployment models. Our approach using static linking with GitHub Releases and a setup tool provides:
- No additional runtime dependencies for end users (beyond standard system libraries)
- Developer-friendly installation process
- Fast download and setup
- Transparent and auditable
- Cross-platform support
- Single-binary deployment that preserves Go’s philosophy
The key insight is choosing static over dynamic linking. While it increases binary size, it eliminates runtime dependencies entirely, ensuring that applications built with our Go library can be distributed as truly self-contained binaries.
While not perfect, it solves the core problem: developers can use CGO-based Go modules without complex build toolchains, and end users can run the resulting applications without installing any native libraries.
The SLIM Go bindings demonstrate this approach in production. If you’re building Go modules with native dependencies and want to maintain Go’s single-binary deployment promise, consider adapting this pattern for your own projects.
References
- SLIM Go Bindings Repository
- SLIM Main Repository
- UniFFI - Unified Foreign Function Interface
- CGO Documentation
- SLIM Documentation
Have questions about this approach or want to discuss alternative solutions? Join our Slack community or check out our GitHub.