Guix Home on a Foreign Distro: Features per Machine and Tooling per Project

Posted on 2026-11-01
TL;DR
You do not have to switch your Linux distribution to profit from Guix. I use Guix Home on top of Ubuntu, as well as Debian, to manage my configuration by using small features combined per machine. That way I provide every client workspace with its own tools like Java, VPN and database helpers.

There are plenty of introductions to the Guix package manager out there, so this is none of them. I rather wanted to share two ideas I use in my own current setup that made Guix useful for me without touching the operating system underneath. The first one is a way to structure the machines user level configuration as a whole in small features that I define using scheme lisp. They can be combined in different ways for different machines. The second one is to define different project workspaces that only have exactly the tooling that they need. This will become more clear in just a minute.

For all that I run Guix Home on top of my regular linux distribution, currently Ubuntu 24.04 on my work laptop and Debian unstable on my personal MNT Reform1. Which distribution it is does not matter much. This would be applicable to any linux system you have. The distribution keeps managing the base system: The kernel, drivers, the display stack and audio system. In addition to that I use the guix package manager to manage and provision everything in my home directory. That would be my user’s programs, dotfiles containing my programms configuration and in addition to that user level background services that run under my personal user’s permissions.

Okay wow, but I can handle my system without leaning lisp, Ben!

Sure, but let me show you what got me excited to try this. First you can view my configuration some sort of a “backup” that can be applied to set up a new system. Say, when I set up a new machine I install guix, clone the repository and bootstrap the configuration from there. It installs all programms I need, my shell, my editor, my window manager config and my music daemon and all configurations for those. It’s configuration as code for my workstations.

Second, Guix is also a nice place to put small scripts, configs and tools. A package definition for a script or a tool of my own is only a few lines, and it installs like any other package. I can define the dependencies and have everything installed. Potentially if somebody else would be interested, I could provide packages to others through my guix channel, a special kind of git repository, as well. That makes writing own tools way more applicable than just having everything laying around.

Defining Features

Let’s dive a bit deeper. I split my whole configuration into what I call features. How does that look like? A feature is just a Guile module defined in lisp that exports two lists: the package names it needs and the services it sets up. Once they are defined, a host file, so the one that describes what goes onto a Machine (host), just defines the features that machine should have.

This is how the files are laid out in my repository, which lives on the guix config directory:

~/.config/guix/
└── modules/bbr/
    ├── hosts/
    │   ├── lt1550.scm     picks the features for my laptop
    │   └── reform.scm     picks the features for the Reform
    └── features/
        ├── base.scm
        ├── desktop.scm
        ├── music.scm      exports music-packages and music-services
        ├── music/
        │   └── mpd.conf   a plain config file, deployed as it is
        └── radio.scm

At evaluation time, guile finds a module by its name: (bbr features music) for example corresponds to the file bbr/features/music.scm. Configuration file templates for programs like mpd.conf are not modules, they just sit in a directory next to the feature that uses them.

As an example here is the first part of features/music.scm. It is a feature for my music player setup, and the package names defined in music-packages are the same ones you would pass to guix install:

(define-module (bbr features music)
  #:use-module (gnu home services)
  #:use-module (gnu home services shepherd)
  #:use-module (gnu packages mpd)
  #:use-module (guix gexp)
  #:export (music-packages music-services))

(define music-packages
  '("ncmpc" "mpd" "mpd-mpc"))

This defines my music player setup. mpd is the music player daemon, ncmpc a terminal client for it and mpd-mpc a small command line tool to control it. Importing and using the packages like this is enough to get them installed. In the file there are also definitions for how to run mpd as a shepherd service, as well as how and where do deploy mpd’s configuration to:

(define mpd-shepherd-service
  (shepherd-service
    (documentation "Run the Music Player Daemon.")
    (provision '(mpd))
    (modules '((shepherd support)))
    (start #~(make-forkexec-constructor
              (list #$(file-append mpd "/bin/mpd") "--no-daemon" "--stderr"
                    (string-append (getenv "HOME") "/.config/mpd/mpd.conf"))
              #:log-file (string-append %user-log-dir "/mpd.log")))
    (stop #~(make-kill-destructor))))

(define music-services
  (list (simple-service 'music-shepherd home-shepherd-service-type
                        (list mpd-shepherd-service))
        (simple-service 'music-files home-files-service-type
                        `((".config/mpd/mpd.conf" ,(local-file "music/mpd.conf"))))))

I’ve defined several other features like this. For example my emacs config, or my work workplaces. Next, how do I assign them to machines now?

Assigning Features to Hosts

A host can bundle the features together and later deploy them towards a linux machine. The Reform has fewer features than my laptop because I do not need work related stuff there, so it’s host file is shorter. Here you can see the main part of hosts/reform.scm, which lists the features of this machine:

(home-environment
  (packages (specifications->packages
              (append base-packages
                      emacs-packages
                      desktop-packages
                      music-packages
                      radio-packages)))
  (services (append base-services
                    music-services
                    desktop-services
                    emacs-services)))

A feature that needs a background process or a dotfile adds that by using a service extension. The music feature for example, runs the Music Player Daemon under Shepherd, the user-level service manager of Guix Home, and deploys its configuration file next to it. That is the what you saw in the second part of features/music.scm.

When I want my music setup on a new machine, I add music-packages and music-services to its host file. When I do not want it anymore, I remove those two names.

The deploy command points Guix at the directories with my modules, and the host file of the current machine is the one to apply. The second -L is the directory with the package definition of one of my own tools, more on that below:

guix home -L ~/.config/guix/modules -L ~/dev/archiving/packaging \
  reconfigure ~/.config/guix/modules/bbr/hosts/reform.scm

Packages that are not in Guix yet

A feature can also use packages that do not exist in Guix at all. For example my radio feature does that. It installs two small command line tools I have written and packaged myself.

  • azi-qso-map generates azimuthal maps from the log files of my amateur radio activations, which I described in a previous post.

The package definition of azi-qso-map is published in my own Guix channel, a git repository that Guix pulls package definitions from. That can be wrapped in it’s own feature definition like this:

(define-module (bbr features radio)
  #:use-module (asciijungle packages azi-qso-map)
  #:export (radio-packages))

(define radio-packages
  '("azi-qso-map"))

For the host it makes no difference whether a package comes from Guix itself or from my own definitions. That concludes what I wanted to show regarding the feature composition.

Workspaces: one directory per customer

Another interesting part for my daily work is how I handle projects workspaces in this setup. Each client at work gets its own directory below ~/dev, for example ~/dev/client-a and ~/dev/client-b. All the repositories of that customer live inside. A single work feature deploys what each workspace needs, and a few things happen automatically as soon as I enter the directory.

The right git identity

Every customer has its own email address, SSH key and commit signing rules. Git can switch these based on the location of the repository:

[includeIf "gitdir:~/dev/client-a/**"]
    path = ~/dev/client-a/client-a.gitconfig

[includeIf "gitdir:~/dev/client-b/**"]
    path = ~/dev/client-b/client-b.gitconfig

Each included file sets user.email, the SSH key to use and whether commits are signed. This git config is managed by my work feature in guix.

Installing different Software Tools per Workspace

Customers do not agree on tooling. One project needs a certain Java and Python, another one needs a database client library, Node.js and Ansible on top. I do not want any of this installed globally, and I do not want to remember which version belongs where.

Each workspace gets a manifest, which is just a list of Guix packages:

(specifications->manifest
  '("openjdk@25.0.2:jdk"
    "python"))

Next to it Guix Home deploys an .envrc for direnv2:

use guix -m manifest.scm

JAVA_HOME="$(dirname "$(dirname "$(readlink -f "$(which java)")")")"
export JAVA_HOME

PATH_add bin

This enables that when I cd into ~/dev/client-a with a shell, direnv builds an environment from the manifest and puts those packages on my PATH. JAVA_HOME is derived from the Java that is now first on the PATH. When I leave the directory, everything is gone again. java in ~/dev/client-b is a different one than in ~/dev/client-a, and in my home directory there is none at all. A project that still needs an older Java only changes one line in the manifest of its workspace.

Installing small helper scripts

The last line of the .envrc, PATH_add bin, makes a bin directory of the workspace available. Home deploys small scripts there that only make sense for that customer. Two I use all the time are called vpn and db in almost every workspace, so these work the same way for each customer3.

The vpn script opens the tunnel to the customer’s network, simplified like this:

#!/bin/sh
cloudflared access tcp --hostname https://vpn.example.com --url localhost:6432

The db script then connects to the test database through that tunnel. It reads the credentials from the cluster and starts psql, either interactively or with a query given as an argument:

db "select count(*) from accounts"

In another workspace, db talks to a completely different kind of database with different tools, but I type the same thing. The knowledge about how to reach test data is part of the configuration instead of living only in my head or in an outdated wiki page.

Wiring it all up

All of this is declared in the work feature. It is one list of files to deploy:

(define work-services
  (list (simple-service 'work-files home-files-service-type
                        `(("dev/client-a/.envrc" ,(local-file "work/client-a.envrc"))
                          ("dev/client-a/manifest.scm" ,(local-file "work/client-a.manifest"))
                          ("dev/client-a/bin/vpn" ,(local-file "work/client-a-vpn" #:recursive? #t))
                          ("dev/client-a/bin/db" ,(local-file "work/client-a-db" #:recursive? #t))
                          ("dev/client-b/.envrc" ,(local-file "work/client-b.envrc"))
                          ("dev/client-b/manifest.scm" ,(local-file "work/client-b.manifest"))))))

The work feature is only part of my laptop’s host file. The Reform has no customer directories at all and therefore does not get any of this.

I really like this way of setting things up. It allows me to keep things separated and orientate myself quickly. It would even be possible to share configurations accross the team if more people want to use guix for their config setup.

Limitations and Challenges I’ve encountered

While setting this up and using it for more and more aspects of my system I came across a few challenges and also limitations of Guix.

First of all the performance of Guix is not blazingly fast. The first apply takes a long time. That is because there are several parts of the system that are compiled and all the dependencies have to be fetched onto the machine. On the MNT Reform it ran for several hours until everything was in place. Later changes are fast, because only what changed gets rebuilt but setting up initially just takes time.

Another thing is that I do not manage everything on my machine using Guix. Some tools I still manage from the distribution, those are for example parts of the desktop, the sound system, system tools etc. The distribution stays in charge of the base system. Moving to Guix OS is an option for the future but honestly the system works like this too. Of course there are also propietery tools that are not offered via guix. AWS cli, intellij and google chrome for example. Those have to still be installed and updated by hand.

The first rollout to another machine also lead to small surprises. A different version of a program in another distribution can mean a differently named setting in a config file for the desktop for example. That is why it makes sense to keep the system configurations alike as much as possible. My host files handle those differences with a parameter, but I only find them by trial and error. That being said it is awesome that guix can manage this after all. The two laptops that I provision run on different processor architectures (AMD 64 vs aarch64) and I was able to set everything up with only minor changes.

Conclusion

For me, the combination of features and workspaces is what makes Guix Home worth it. Features make my configuration modular with a machine definition that is just a short list of identifiers. Workspaces make my customer work tidy with the right git identity, the correct tools and the command line helpers that show up when I enter a disoctory and disappear when I leave again. To me that is a nice benefit which got me enjoying this setup at the moment. To be fair this can also be accomplished by just using direnv but without guix I find this tedious to pull off.

And I am aware that you can accomplish similar things with different tools. If you have used Nix, devenv or plain version managers for similar things, I’d be curious to learn about your setup and how your solution compares to this. And if you’d like to see more details about a part of this setup just let me know. As always I’m happy to get in touch. Cheers and thanks for taking the time.

Footnotes


  1. The MNT Reform is an open hardware laptop. I wrote about backing it a while ago. Mine is an aarch64 machine, so it was a good test for a configuration that should work on more than one processor architecture.↩︎

  2. direnv is an awesome tool that loads and unloads environment variables depending on the current directory. Its standard library has a use guix function that builds the environment from a manifest with guix shell. This is the base I use for having the same command behave differently in different environments.↩︎

  3. #:recursive? #t in local-file copies the file into the store including its permissions. Without it the deployed script would not be executable. I think the naming here is a bit missleading.↩︎

Portrait of the blog's author. Dude with full beard and short hair.
Author: Benjamin Brunzel I'm a software engineer based in Weilheim i.Ob., Germany. If you want to get in touch contact me in the fediverse