1060 words, 6 min read

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:

  1. Copies the .deb into the repository's pool directory
  2. Updates the Packages index files for the relevant architecture
  3. Regenerates and re-signs the Release / InRelease metadata

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.