If you are an avid reader of my adventures, firstly I would like to offer my condolences. Secondly, you may recall that I have this unquenchable thirst to shove “Hello, World!” into absolutely everything. I’ve been avoiding dealing with Docker registries and then thought, what if I avoided containers as well. What lunatic needs a container runtime installed to enjoy a little “Hello, World!”? Just put the binary on the box and then I can enjoy this bit of my illness whenever I want.
Turns out Debian has a mechanism for doing this since the early 90s, and I’ve
spent most of my career treating the .deb as some form of witchcraft that
only the greyest of beards knew how to deal with. It got to the point where in
particular work projects I was trying figure out how I could try and budget
grey beards from Gumtree into tenders. Turns out the greybeard market is
wearing pretty thin these days, so I’ve always resorted to very unholy
install bash scripts.
So, did someone say nfpm? Imagine writing a simple YAML file and just having
it compile a project into a .deb, .rpm and/or .apk without having to
sacrifice any goats. As usual, this post starts with the actual useful part
then forgets what I was even doing.
Building a .deb
I’m just going to steal the simple Hello World go code from the
Docker Save post and call it rosie again for character. To get started
with nfpm you just need to create the nfpm.yaml file:
name: rosie
arch: amd64
platform: linux
version: 1.0.0
maintainer: Lachlan Cox <spam@lachlancox.dev>
description: Says hello. Yes I'm serious.
license: Unlicensed
contents:
- src: ./rosie
dst: /usr/bin/rosie
file_info:
mode: 0755
This will create the package for amd64 with the ./rosie binary. This means
that you will have to build the binary before you can package it. So lets do
that and then package it for Debian.
~/dev/rosie
❯ GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o rosie .
~/dev/rosie
❯ nfpm pkg --packager deb
using deb packager...
created package: rosie_1.0.0_amd64.deb
~/dev/rosie
❯ ls -lh
total 7744
-rw-r--r--@ 1 lcox74 staff 42B 17 Aug 17:15 go.mod
-rw-r--r--@ 1 lcox74 staff 74B 17 Aug 17:13 main.go
-rw-r--r--@ 1 lcox74 staff 215B 17 Aug 17:24 nfpm.yaml
-rwxr-xr-x@ 1 lcox74 staff 2.4M 17 Aug 17:24 rosie
-rw-r--r--@ 1 lcox74 staff 1.4M 17 Aug 17:24 rosie_1.0.0_amd64.deb
That’s it. You can set the --packager to be rpm and apk and it will just
build accordingly. I’m actually going to say bugger it and build an .apk then
install it on an Alpine Docker container to make sure it works. Since I’m on
Apple silicon and I want the container running natively, the arch in
nfpm.yaml needs to swap over first:
arch: arm64
Worth noting that nfpm takes the Debian architecture name in the config but
uses each packager’s own spelling in the filename, which is why an arm64
build falls out the other side as rosie_1.0.0_aarch64.apk.
~/dev/rosie
❯ GOOS=linux CGO_ENABLED=0 go build -o rosie .
~/dev/rosie
❯ nfpm pkg --packager apk
using apk packager...
created package: rosie_1.0.0_aarch64.apk
~/dev/rosie
❯ docker run --rm -v /Users/lcox74/dev/rosie:/pkg:ro alpine:latest \
sh -c 'apk add --allow-untrusted /pkg/rosie_1.0.0_aarch64.apk && rosie'
(1/1) Installing rosie (1.0.0)
Executing busybox-1.37.0-r31.trigger
OK: 10.5 MiB in 17 packages
Hello, World!
Signing
You may have noticed I had to add the --allow-untrusted flag to the install
command. This is because APK requires signing of packages, but fortunately
this is relatively easy to deal with. You just need to generate a key and
then have nfpm sign the package. This can be done by adding the following
section to the nfpm.yaml and then just repackaging it.
apk:
signature:
key_file: ./rosie@lachlancox.dev-1a2b3c4d.rsa
key_name: rosie@lachlancox.dev-1a2b3c4d.rsa.pub
These keys don’t exist yet, I need to make them. Alpine seems to have some
sort of naming standard for these keys: <email>-<8 hex>.rsa.pub. I went
looking for ed25519 first out of habit and came up empty: apk v2 signatures
are RSA, and nfpm’s apk signer expects an RSA key, so RSA it is. Once
these are made with the following commands then we can just copy the public
key into the /etc/apk/keys/ directory and install it without the
--allow-untrusted flag.
~/dev/rosie
❯ openssl genrsa -out rosie@lachlancox.dev-1a2b3c4d.rsa 4096
~/dev/rosie
❯ openssl rsa -in rosie@lachlancox.dev-1a2b3c4d.rsa -pubout \
-out rosie@lachlancox.dev-1a2b3c4d.rsa.pub
writing RSA key
~/dev/rosie
❯ nfpm pkg --packager apk
using apk packager...
created package: rosie_1.0.0_aarch64.apk
~/dev/rosie
❯ docker run --rm -v /Users/lcox74/dev/rosie:/pkg:ro alpine:latest sh -c '
cp /pkg/rosie@lachlancox.dev-1a2b3c4d.rsa.pub /etc/apk/keys/
apk add /pkg/rosie_1.0.0_aarch64.apk && rosie'
(1/1) Installing rosie (1.0.0)
Executing busybox-1.37.0-r31.trigger
OK: 10.5 MiB in 17 packages
Hello, World!
This is all pretty cool. This key method may seem dodgy, but it is just how
it’s done in Alpine. There’s no container registry sitting in front of it to
hide the trust problem. The official repositories are signed, and the keys
that verify them ship as an ordinary package called alpine-keys that drops
.rsa.pub files into the exact same directory I just used. You can see them
with an apk info -L alpine-keys on the machine.
~/dev/rosie
❯ docker run --rm alpine:latest sh -c 'apk info -L alpine-keys'
WARNING: opening from cache https://dl-cdn.alpinelinux.org/alpine/v3.24/main/aarch64/APKINDEX.tar.gz: No such file or directory
WARNING: opening from cache https://dl-cdn.alpinelinux.org/alpine/v3.24/community/aarch64/APKINDEX.tar.gz: No such file or directory
alpine-keys-2.6-r0 contains:
etc/apk/keys/alpine-devel@lists.alpinelinux.org-58199dcc.rsa.pub
etc/apk/keys/alpine-devel@lists.alpinelinux.org-616ae350.rsa.pub
usr/share/apk/keys/alpine-devel@lists.alpinelinux.org-4a6a0840.rsa.pub
usr/share/apk/keys/alpine-devel@lists.alpinelinux.org-5243ef4b.rsa.pub
...
usr/share/apk/keys/x86_64/alpine-devel@lists.alpinelinux.org-5261cecb.rsa.pub
usr/share/apk/keys/x86_64/alpine-devel@lists.alpinelinux.org-6165ee59.rsa.pub
Which raises the obvious chicken-and-egg. You can’t apk add the keys you
need in order to verify the thing you’re adding. It seems that Alpine dodges it
by baking alpine-keys into the rootfs tarball, so by the time you have a
working Alpine you already trust the official keys, and apk add alpine-keys
only ever updates them.
So if you want to start making alpine packages you could make a keys package to install and then have all your official packages signed by those keys.
Making It A Service
Copying a binary onto a box is something tar has been doing since before the
dawn of time. Where packaging actually earns its keep is everything around the
binary, so let’s give rosie a systemd unit and have the package wire it up on
install.
[Unit]
Description=Says hello. Wait, why am I in a service file. Somebody help!
After=network.target
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/bin/rosie
[Install]
WantedBy=multi-user.target
Type=oneshot because rosie says its piece and exits, which systemd would
otherwise read as a service falling over on startup.
The unit goes in contents and the wiring goes in scripts:
contents:
- src: ./rosie
dst: /usr/bin/rosie
file_info:
mode: 0755
- src: ./rosie.service
dst: /usr/lib/systemd/system/rosie.service
scripts:
postinstall: ./postinstall.sh
preremove: ./preremove.sh
The scripts for this example are small, though they can get quite complex depending on what you are building. For this I’ll just have it reload the systemd daemon and start the service. The post remove will just be the reverse. Though in more professional settings you would have it create an application specific user and groups to harden the deployment, along with settting up application directory structures.
#!/bin/sh
set -e
# Only poke systemd if it's actually the init system.
if [ -d /run/systemd/system ]; then
systemctl daemon-reload
systemctl enable --now rosie.service
fi
Proving this one needs a container where systemd is actually PID 1, which the
stock debian image is not, so this is tested in WSL2.
~/dev/rosie
❯ dpkg -i /pkg/rosie_1.0.0_arm64.deb
Selecting previously unselected package rosie.
(Reading database ... 6134 files and directories currently installed.)
Preparing to unpack /pkg/rosie_1.0.0_arm64.deb ...
Unpacking rosie (1.0.0) ...
Setting up rosie (1.0.0) ...
Created symlink '/etc/systemd/system/multi-user.target.wants/rosie.service' -> '/usr/lib/systemd/system/rosie.service'.
~/dev/rosie
❯ systemctl status rosie
● rosie.service - Says hello. Wait, why am I in a service file. Somebody help!
Loaded: loaded (/usr/lib/systemd/system/rosie.service; enabled; preset: enabled)
Active: active (exited) since Sat 2026-08-22 06:28:37 UTC; 76ms ago
Process: 148 ExecStart=/usr/bin/rosie (code=exited, status=0/SUCCESS)
Main PID: 148 (code=exited, status=0/SUCCESS)
Mem peak: 4.8M
CPU: 3ms
Aug 22 06:28:37 0020add040b3 systemd[1]: Starting rosie.service...
Aug 22 06:28:37 0020add040b3 rosie[148]: Hello, World!
Aug 22 06:28:37 0020add040b3 systemd[1]: Finished rosie.service...
There it is, in the journal, on a box with no container runtime and no install
script. And because preremove does its job, it goes away just as politely:
~/dev/rosie
❯ dpkg -r rosie
(Reading database ... 6136 files and directories currently installed.)
Removing rosie (1.0.0) ...
Removed '/etc/systemd/system/multi-user.target.wants/rosie.service'.
~/dev/rosie
❯ systemctl status rosie
Unit rosie.service could not be found.
So that’s a binary, a service, and a signed package, out of one YAML file and
some simple scripts. I still don’t own a grey beard, the rarity is outside my
budget, but now I can get “Hello, World!” onto a box in a way that survives a
reboot and uninstalls cleanly. The sketchy install script I would have written
instead still linger in a repo somewhere, performing elderitch rsync and
piping a series of bash script calls.