Distributing a command-line tool to Linux users is straightforward until you want to make it feel native. Tarballs work, but APT packages are what seasoned Linux users expect: a single apt install, automatic updates via apt upgrade, and clean removal with apt remove. This post walks through how we set up a self-hosted APT repository for a Go CLI tool using only a Makefile, a small shell script, and a clever Go utility called aptblob.
The goal
We want users to be able to run:
curl -fsSL https://packages.example.com/pubkey.gpg | sudo apt-key add -
echo "deb https://packages.example.com stable main" | sudo tee /etc/apt/sources.list.d/mycli.list
sudo apt update
sudo apt install mycli
To make that work, we need to produce properly structured .deb packages for each architecture, sign them with a GPG key, and publish an APT repository index that apt can parse.
Step 1: cross-compiling and packaging
Go makes cross-compilation trivial. The build-deb make target compiles the binary twice — once for arm64 and once for amd64 — and wraps each in a proper Debian package.
## build-deb: build the debian package
.PHONY: build-deb
build-deb:
@rm -f ./$(APPNAME)-*
$(call build-binary,linux,arm64,arm64)
$(call build-deb,arm64)
$(call build-binary,linux,amd64,x86_64)
$(call build-deb,amd64)
The build-binary helper looks like this:
define build-binary
@GOOS=$(1) GOARCH=$(2) go build -o $(APPNAME)
endef
The build-deb helper function does all the Debian packaging work. It creates the directory structure that dpkg-deb expects, writes a minimal DEBIAN/control file, places the binary into usr/bin/, and calls dpkg-deb --build to produce the .deb archive:
define build-deb
@mkdir -p $(APPNAME)_$(VERSION)-1_$(1)/DEBIAN
@mkdir -p $(APPNAME)_$(VERSION)-1_$(1)/usr/bin
@mv $(APPNAME) $(APPNAME)_$(VERSION)-1_$(1)/usr/bin/$(APPNAME)
@echo "Package: mycli" > $(APPNAME)_$(VERSION)-1_$(1)/DEBIAN/control
@echo "Version: $(VERSION)" >> $(APPNAME)_$(VERSION)-1_$(1)/DEBIAN/control
@echo "Maintainer: Team <hi@example.com>" >> $(APPNAME)_$(VERSION)-1_$(1)/DEBIAN/control
@echo "Architecture: $(1)" >> $(APPNAME)_$(VERSION)-1_$(1)/DEBIAN/control
@echo "Description: My CLI tool" >> $(APPNAME)_$(VERSION)-1_$(1)/DEBIAN/control
@dpkg-deb --build -Zgzip $(APPNAME)_$(VERSION)-1_$(1)
@rm -rf $(APPNAME)_$(VERSION)-1_$(1)
endef
The -Zgzip flag tells dpkg-deb to use gzip compression, which produces slightly larger packages than xz but is more universally compatible.
The version is injected automatically from the latest git tag:
VERSION := $(shell git describe --tags --abbrev=0)
So tagging a release in git is all it takes to produce the correct package version.
Step 2: the aptblob tool
The real hero of this setup is zombiezen.com/go/aptblob. It is a small Go utility that creates and maintains APT repositories without requiring you to install reprepro, aptly, or any other heavyweight tooling. Because it is fetched and run via go run, there is no separate installation step — Go's module system handles it.
aptblob supports multiple storage backends via URL: file:// for local disk, and cloud storage providers for production hosting.
Step 3: initialising the repository
Before uploading any packages, the repository needs to be bootstrapped with an InRelease file that describes the repository metadata. This is handled by init-repo.sh:
#!/usr/bin/env bash
go run zombiezen.com/go/aptblob@latest init -k $KEY_ID "file://`pwd`/apt-repo" stable <<EOF
Origin: stable
Label: My CLI Repository
Suite: stable
Codename: stable
Version: 1.0
Architectures: arm64 amd64
Components: main
Description: The My CLI software repository
EOF
The -k $KEY_ID flag tells aptblob to sign the repository metadata with a specific GPG key. The key ID is passed via the APT_SIGNING_KEY_ID environment variable so it never appears in source control. The stable argument at the end is the distribution name — the string users put in their sources.list entry.
Step 4: uploading the packages
With the repository initialised, the build-apt-repo target uploads both .deb files:
## build-apt-repo: build the apt repository
build-apt-repo: build-deb
@echo "Building apt repository for version $(VERSION)"
@rm -rf apt-repo
@mkdir apt-repo
@KEY_ID=$(KEY_ID) bash init-repo.sh
@go run zombiezen.com/go/aptblob@latest upload -k $(KEY_ID) \
"file://$(shell pwd)/apt-repo" stable $(APPNAME)_$(VERSION)-1_arm64.deb
@go run zombiezen.com/go/aptblob@latest upload -k $(KEY_ID) \
"file://$(shell pwd)/apt-repo" stable $(APPNAME)_$(VERSION)-1_amd64.deb
@rm *.deb
Each upload call:
- Copies the
.debinto the repository's pool directory - Updates the
Packagesindex files for the relevant architecture - Regenerates and re-signs the
Release/InReleasemetadata
The -k $(KEY_ID) flag is required on every upload because aptblob must re-sign the repository index each time a package is added.
After the upload, the temporary .deb files are cleaned up — the canonical copies now live inside apt-repo/.
Step 5: publishing
The apt-repo/ directory is a fully self-contained, static APT repository. Serving it is as simple as putting it behind any HTTP server or syncing it to a cloud storage bucket with public read access:
# Sync to S3 (or any S3-compatible service)
aws s3 sync ./apt-repo s3://my-packages-bucket/ --delete
# Or rsync to a VPS
rsync -avz ./apt-repo/ user@packages.example.com:/var/www/packages/
Why this approach works well
No daemon or database. Traditional APT repository tools like reprepro maintain a local database and run as a long-lived process. aptblob is entirely stateless — the repository metadata is the state.
Pure Go toolchain. The only external dependencies are gpg (for signing) and dpkg-deb (for building the .deb files). Everything else is managed by go run, which means CI machines don't need any special APT repository software pre-installed.
Reproducible builds. Every release starts from a clean rm -rf apt-repo, so there is no risk of stale packages accumulating silently.
Works with any storage. Today we use file:// for a local build; tomorrow we can switch to an S3 or GCS URL without changing anything else.
The full release workflow
# 1. Tag the release
git tag v1.2.3 && git push --tags
# 2. Build and publish
APT_SIGNING_KEY_ID=ABCDEF1234567890 make build-apt-repo
aws s3 sync ./apt-repo s3://my-packages-bucket/ --delete
That's two commands from tag to published APT package. For a small CLI tool, it is hard to imagine a lighter-weight setup that still delivers a genuine apt install experience.
Summary
| Step | Tool | What it produces |
|---|---|---|
| Cross-compile | go build with GOOS/GOARCH |
Linux binaries (arm64, amd64) |
| Package | dpkg-deb --build |
.deb archives |
| Init repo | aptblob init |
Signed InRelease / Release metadata |
| Upload packages | aptblob upload |
Updated Packages index + pool |
| Serve | Any static HTTP server | Installable APT repository |
If you are shipping a Go CLI and want proper APT distribution without the overhead of a full packaging pipeline, aptblob plus a handful of Makefile targets gets you there in under 50 lines.
If this post was enjoyable or useful for you, please share it! If you have comments, questions, or feedback, you can email my personal email. To get new posts, subscribe use the RSS feed.