Lachlan Cox

~/blog $ cat 2026-08-nfpm-packaging.md

Packaging My Way Out Of Containers

· 7 min read

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.