Skip to main content
  1. Posts/
  2. The Solo Stack/
  3. Dotfiles/

When apt Doesn't Have What You Need

·1135 words·6 mins
Dotfiles - This article is part of a series.
Part 8: This Article

In the previous post, we wired Ansible into chezmoi and installed tmux as our first package. One playbook, one task, one apt install. Clean and simple.

Now I want to add eza — a modern replacement for ls with color-coded output, git awareness, and sensible defaults. It’s one of those tools that, once you’ve used it, makes plain ls feel like staring at a wall of text.

Let’s add it to the playbook.

The Obvious First Attempt
#

Since tmux was a straightforward apt install, let’s try the same thing with eza. Inside the container:

root@container:~# apt-cache search eza

Nothing. Let’s try installing it anyway, just to confirm:

root@container:~# apt-get install -y eza
Reading package lists... Done
Building dependency tree... Done
E: Unable to locate package eza

That’s clear enough. Debian bookworm doesn’t ship eza. It’s not that the version is too old — the package simply doesn’t exist in the repos.

This is a different problem from the one we had with age. With age, the package existed but was outdated. With eza, there’s nothing to install at all. We can’t just pin a version or grab a binary — we need to teach apt where to find it.

Adding an External apt Repository
#

The eza project maintains its own Debian repository. To use it, we need to do three things:

  1. Download their GPG key — so apt can verify that packages actually come from them
  2. Add their repository — so apt knows where to look
  3. Install the package — now that apt can find it

In shell commands, that looks like this:

sudo apt-get install -y gpg
sudo mkdir -p /etc/apt/keyrings
curl -fsSL https://raw.githubusercontent.com/eza-community/eza/main/deb.asc \
  | sudo gpg --dearmor -o /etc/apt/keyrings/gierens.gpg
echo "deb [signed-by=/etc/apt/keyrings/gierens.gpg] http://deb.gierens.de stable main" \
  | sudo tee /etc/apt/sources.list.d/gierens.list
sudo chmod 644 /etc/apt/keyrings/gierens.gpg /etc/apt/sources.list.d/gierens.list
sudo apt update && sudo apt install -y eza

There’s one prerequisite that’s easy to miss: gpg isn’t installed by default on bookworm-slim. Without it, the gpg --dearmor step will fail. That’s why the first command installs it.

A few other things worth noting:

  • /etc/apt/keyrings/ is the modern location for third-party GPG keys. Older guides might tell you to use apt-key add, but that’s deprecated.
  • gpg --dearmor converts the ASCII-armored key into binary format, which is what apt expects.
  • curl -fsSL — we’ve been using these flags throughout the series: fail on error, silent, show errors, follow redirects. curl is already part of our bootstrap, so no extra dependency needed.
  • The signed-by field in the sources list ties this specific repository to its GPG key. Other repos can’t use this key, and this repo can’t use other keys. It’s a scoped trust model.

Translating to Ansible
#

This is where Ansible earns its keep. The shell commands above work, but they’re not idempotent — run them twice and you’ll get warnings about existing files. Let’s express the same logic as Ansible tasks.

Here’s what I added to ansible/playbook.yml:

---
- name: Setup workstation
  hosts: localhost
  connection: local
  become: true

  tasks:
    - name: Install tmux
      ansible.builtin.apt:
        name: tmux
        state: present
      when: ansible_os_family == "Debian"

    - name: Install eza
      block:
        - name: Install gpg (required for keyring setup)
          ansible.builtin.apt:
            name: gpg
            state: present

        - name: Ensure /etc/apt/keyrings directory exists
          ansible.builtin.file:
            path: /etc/apt/keyrings
            state: directory
            mode: '0755'

        - name: Download and dearmor eza GPG key
          ansible.builtin.shell:
            cmd: >
              curl -fsSL https://raw.githubusercontent.com/eza-community/eza/main/deb.asc
              | gpg --dearmor -o /etc/apt/keyrings/gierens.gpg
            creates: /etc/apt/keyrings/gierens.gpg

        - name: Add eza apt repository
          ansible.builtin.apt_repository:
            repo: "deb [signed-by=/etc/apt/keyrings/gierens.gpg] http://deb.gierens.de stable main"
            filename: gierens
            state: present

        - name: Set correct permissions on keyring and sources list
          ansible.builtin.file:
            path: "{{ item }}"
            mode: '0644'
          loop:
            - /etc/apt/keyrings/gierens.gpg
            - /etc/apt/sources.list.d/gierens.list

        - name: Install eza
          ansible.builtin.apt:
            name: eza
            state: present
            update_cache: true

Let’s break down the new concepts.

block
#

The block keyword groups multiple tasks together. On its own that’s just organizational, but it becomes powerful when combined with conditions or error handling — you can apply a when clause or a rescue block to the entire group instead of repeating it on every task. Here, it keeps the eza installation self-contained: all five tasks belong together as a logical unit.

creates
#

- name: Download and dearmor eza GPG key
  ansible.builtin.shell:
    cmd: >
      curl -fsSL https://raw.githubusercontent.com/eza-community/eza/main/deb.asc
      | gpg --dearmor -o /etc/apt/keyrings/gierens.gpg
    creates: /etc/apt/keyrings/gierens.gpg

Ansible modules like apt are idempotent by default — they check state before acting. But when you use shell, Ansible doesn’t know what your command does. The creates parameter fixes that: if the specified file already exists, Ansible skips the task entirely. This turns a raw shell command into something that behaves idempotently.

apt_repository
#

- name: Add eza apt repository
  ansible.builtin.apt_repository:
    repo: "deb [signed-by=/etc/apt/keyrings/gierens.gpg] http://deb.gierens.de stable main"
    filename: gierens
    state: present

Instead of piping into tee, Ansible’s apt_repository module manages /etc/apt/sources.list.d/ entries declaratively. The filename parameter controls the output file name, and state: present means “make sure this exists.” If it’s already there, nothing happens.

update_cache
#

The final apt task includes update_cache: true. After adding a new repository, apt needs to fetch its package index before it can install anything from it. This is the Ansible equivalent of apt update && apt install.

Testing It
#

Same flow as always — disposable container:

podman run -it --rm debian:bookworm-slim bash
apt update -y && apt upgrade -y && apt install curl git nano -y
mkdir -p ~/.config/age
chmod 700 ~/.config/age
touch ~/.config/age/dotfiles-repo-key.txt
chmod 600 ~/.config/age/dotfiles-repo-key.txt
nano ~/.config/age/dotfiles-repo-key.txt  # paste in key
sh -c "$(curl -fsLS get.chezmoi.io)" -- init --apply knuth-info
zsh

After chezmoi finishes, check that eza is installed:

eza --version  # should print version info

And try it out:

eza -la

If you see a nicely formatted, color-coded directory listing — it worked. The full chain ran: chezmoi installed Ansible, Ansible added the external repository, and then installed eza from it.

What We’ve Achieved So Far
#

After chezmoi init --apply, a fresh machine now has:

  • zsh installed and set as default shell
  • The correct version of age, installed from source
  • SSH keys decrypted and in place
  • Git identity configured for the dotfiles repo
  • SSH key linked to git, remote switched to SSH
  • Ansible installed and used to manage software packages
  • tmux installed via Ansible
  • eza installed from an external apt repository via Ansible

What’s Next
#

The playbook just got more interesting. We went from a single apt install to a full external repository setup — GPG key, sources list, scoped trust. This is the same pattern you’ll encounter whenever you need to install software that Debian doesn’t ship by default. Kubernetes tooling, Docker CE, Hashicorp tools — they all use this exact flow.

Next, I want to add fzf to the setup. But fzf has a different problem: the version in bookworm’s repos is so old it’s practically useless. That calls for a different approach — downloading a binary release directly from GitHub.

Marcus Knuth
Author
Marcus Knuth
Dotfiles - This article is part of a series.
Part 8: This Article

Related

Automating age Installation with chezmoi

·940 words·5 mins
In the previous article, we encrypted our SSH keys with age and stored them in the chezmoi repo. That’s great — but there’s a gap. When we set up a new machine and run chezmoi init, chezmoi needs age to decrypt those keys. And as we saw earlier in the series, the version of age available via apt on Debian is too old for post-quantum support.