Wai Hon's Blog

Switching to GNU Guix: A Beginner's Perspective

2026-09-13 #linux #guix

Contents

Background: A Decade of Arch Linux

Arch Linux was my distribution of choice for more than a decade. With its rolling-release model, minimal base, and the invaluable ArchWiki, it felt like the final distribution I would ever need.

My primary Linux machine is a dedicated home server, handling services like Home Assistant, local DNS, background jobs, and developer sandboxes. For a server running 24/7, long-term stability and maintainability are critical. Over years of incremental tweaks, configuration entropy inevitably crept in. System state became scattered across /etc, /usr, systemd service units, and package manager transactions. Whenever I made changes, I had to keep diligent notes about which files were edited, when, and why.

Recent events, such as the Arch Linux AUR security incidents (which I touched upon in my previous post on Caddy) and developments around Omarchy, prompted me to re-evaluate my setup. I wanted an operating system that was declarative, reproducible, and manageable entirely in code.

NixOS vs GNU Guix

Declarative operating systems offer a compelling answer to configuration drift. When researching options, NixOS was actually my first choice.

Testing NixOS in a VM

I spun up a NixOS virtual machine and spent time experimenting by replicating the core services I was running on Arch to ensure everything worked properly. It worked really well: declaring the entire system state in a configuration file with instant rollback capabilities felt like the right model for operating systems.

However, as I explored deeper, documentation in NixOS became a major source of friction. The newer nix command line interface and Flakes remain experimental features that are not yet enabled by default or standardized across the ecosystem, leading to divergent documentation and tutorials. Finding guidance was further complicated by the presence of two separate wikis.

Discovering GNU Guix

While learning more about NixOS, I came across David Wilson’s video from System Crafters: Why I Choose Guix Over NixOS. As a fan of David Wilson, his arguments resonated strongly with me. Shortly after, I also watched YouTux’s video, One of the Best Linux Distros Isn’t Even in DistroWatch’s Top 100.

These videos prompted me to research GNU Guix and try it inside a VM.

GNU Guix shares the same core architectural foundation as NixOS (functional package management, declarative configuration, and atomic rollbacks), but its design choices felt much more cohesive:

  1. Language (GNU Guile Scheme vs Nix DSL): Nix uses its own bespoke domain-specific language. Guix configurations are written entirely in GNU Guile, a general-purpose Scheme (Lisp). As an Emacs user accustomed to Emacs Lisp, Scheme felt familiar and expressive. Rather than learning a specialized configuration syntax, I could leverage a real programming language with first-class functions, macros, and modules.
  2. Init System (GNU Shepherd vs systemd): NixOS builds on systemd, while Guix System uses GNU Shepherd as its service manager. In Guix, Shepherd services are also defined in Guile Scheme. Everything from package recipes to system daemons to PID 1 shares a unified language and data model.
  3. Documentation: Guix’s documentation is remarkably cohesive. Even though some community tutorials can be dated, the official GNU Guix reference manual is consistent, comprehensive, and avoids the fragmented wiki landscape of Nix.
  4. Philosophy (GNU Libre Standards vs Pragmatism): NixOS takes a pragmatic stance, offering toggles for proprietary software and unfree drivers. GNU Guix strictly adheres to the GNU Free System Distribution Guidelines, shipping the Linux-libre kernel and free software exclusively by default.

This philosophical strictness has its trade-offs. For my home server, which connects to the network through an Ethernet cable, proprietary Wi-Fi firmware is unnecessary, and the Linux-libre kernel works out of the box. The purity and auditability feel satisfying, though it does mean dealing with a more curated package catalog.

Between the familiarity of Scheme, the unified architecture, and its close kinship with Emacs, I decided to make the switch to GNU Guix.

What I Like as a Beginner

Unified Declarative Config in Git

With GNU Guix, the entire operating system configuration lives in code and is tracked in Git. I manage both Guix System (operating system declarations, system daemons, kernel parameters) and Guix Home (user packages, shell environments, and dotfiles) within a single literate Org-mode file (guix.org) using Org Babel.

At any point, I can see exactly which packages are installed and which services are active directly from the codebase. In the past, I hesitated to invest in complex system configurations because maintaining them across updates was fragile. With Guix, configuring the operating system feels as manageable and predictable as tweaking my Emacs configuration.

Flexible Guile Configuration

Guix configurations are written in a full-featured programming language rather than static YAML or JSON. This gives immense flexibility when composing services.

Using Guix’s service extension mechanism with simple-service, you can extend existing system services cleanly without modifying base declarations. In a traditional distribution, deploying a service forces you to fragment its configuration across completely separate subsystems: a systemd unit in /etc/systemd/system/, directory initialization in /etc/tmpfiles.d/, and reverse proxy blocks in /etc/nginx/conf.d/.

With Guix, you can co-locate a service and its surrounding infrastructure side by side in the exact same configuration block:

;; Home Assistant container
(simple-service 'home-assistant-container
                oci-service-type
                (oci-extension
                 (containers
                  (list
                   (oci-container-configuration
                    (provision "home-assistant")
                    (image "ghcr.io/home-assistant/home-assistant:stable")
                    ...details config...)))))

;; Inject the Nginx reverse proxy configuration for Home Assistant
(simple-service 'home-assistant-nginx-server
                nginx-service-type
                (list
                 (nginx-server-configuration
                  (inherit ssl-server-configuration)
                  (server-name (list (string-append "home." domain)))
                  (locations
                   (list
                    (nginx-location-configuration
                     ...details config...)))))

This architectural clarity makes understanding, modifying, or removing a service self-contained and painless.

Streamlined Shepherd Timers

Defining scheduled jobs in Guix is also much more streamlined than in traditional distributions. In systemd, setting up a recurring job requires declaring a .service file and a separate .timer unit.

With GNU Shepherd in Guix, you can declare the executable script, its dependencies, and its schedule together in a single Guile expression:

(let* ((duckdns-script
        (program-file
         "duckdns-update"
         (with-extensions (list guile-gnutls) ;required by (web client)
                          #~(begin
                              (use-modules (ice-9 textual-ports)
                                           (web client))
                              (let ((token (string-trim-both
                                            (call-with-input-file "/etc/secrets/duckdns.token"
                                              get-string-all)))
                                    (query-template (string-append "https://www.duckdns.org/"
                                                                   "update?domains=<mydomain>"
                                                                   "&token=~a&ip=")))
                                (http-get (format #f query-template token)))))))
       (duckdns-timer
        (shepherd-timer '(duckdns)
                        "*/5 * * * *"
                        #~(#$duckdns-script)
                        #:requirement '(networking)
                        #:documentation "Update personal domain IP on DuckDNS every 5 minutes.")))
  (simple-service 'duckdns-timer
                  shepherd-root-service-type
                  (list duckdns-timer)))

Sandboxed Containers with guix shell

The guix shell command has transformed how I run ad-hoc software. Instead of polluting my profile with one-off utilities, guix shell creates an ephemeral environment that is cleaned up when the session ends.

Furthermore, its container mode (guix shell -C or --container) makes lightweight isolation trivial. By specifying exactly which directories (--share) and network access (--network) to expose, I can run untrusted commands or AI coding agents inside an isolated sandbox.

For example, I run the Antigravity CLI within a sandbox granting access only to the current working directory, its configuration, and necessary binaries:

# Run Antigravity CLI in a Guix sandbox with access to its config and the project directories.
agy-guix() {
    guix shell --container --network --emulate-fhs \
         --share="$PWD" \
         --share="$HOME/.gemini" \
         --share="$HOME/.local/bin" \
         --preserve='^(TERM)$' \
         coreutils nss-certs bash guix guile emacs git ripgrep fd zip unzip -- $HOME/.local/bin/agy "$@"
}

Knowing an agent cannot access arbitrary files outside its granted path makes experimentation much safer.

Purity and the Minimal Bootstrap Seed

By default, GNU Guix is strictly libre. It ships with the Linux-libre kernel and avoids proprietary binary blobs.

Beyond day-to-day use, Guix’s architectural focus on bootstrapping integrity (reducing the bootstrap binary seed down to around 357 bytes through the stage0/Mes bootstrap) provides a strong sense of technical rigor. Knowing the system can be built from minimal, auditable foundations provides real trust in the underlying stack.

Issues and How I Handle Them

Slow guix pull and Source Builds

A fundamental difference between Guix and Arch Linux is that Guix is a source-based distribution at its core, backed by substitute servers that distribute prebuilt binaries.

If a newly pulled channel commit has not yet been built by the substitute build farm (such as ci.guix.gnu.org or Bordeaux), your machine will fall back to compiling the packages locally. While this architecture empowers powerful capabilities like guix challenge (verifying build reproducibility against other servers) and guix time-machine (traveling back to any historical revision), waiting for long local builds on a home server can be tedious.

To avoid unexpected local compilation, my practical workaround is to pin guix pull to a specific commit from a day or two earlier, ensuring substitutes are already built and cached:

;; ~/.config/guix/channels.scm (or: guix pull --commit=<hash>)
(list (channel
       (inherit %default-guix-channel)
       ;; faster pull via Codeberg mirror
       (url "https://codeberg.org/guix/guix.git")
       (commit "5b52edf051a020947b1d4859853799f2bbce176e")))

Addressing the Package Gap

The most noticeable hurdle for a beginner coming from Arch Linux is repository size. The official Guix channel has strict libre standards and a smaller catalog than the Arch User Repository (AUR). Common utilities like hugo and caddy are not present in the official channel.

In practice, there are several practical ways I handle this gap:

1. Writing Custom Package Definitions

Writing a package definition in Scheme is straightforward. You can define a package that builds from source or downloads an official upstream release archive:

(define hugo
  (package
    (name "hugo")
    (version "0.165.0")
    (source (origin
              (method url-fetch)
              (uri (string-append
                    "https://github.com/gohugoio/hugo/releases/download/v"
                    version "/hugo_" version "_linux-amd64.tar.gz"))
              (sha256 (base32 "0..."))))
    (build-system trivial-build-system)
    ...))

For Hugo, I created a local package definition downloading the official prebuilt binary, making it available seamlessly to my system and deploy scripts.

2. Running Ephemeral Toolchains via guix shell

For tools that exist within language ecosystems, guix shell can pair with ecosystem runners (uvx, npx) without installing packages globally:

# That is how I publish my blog to Cloudflare now.
guix shell node -- npx -y wrangler

3. Generating Definitions with guix import

When a package is missing, guix import can automatically generate package definitions from upstream registries, such as PyPI, Crates.io, CPAN, or GNU ELPA. This significantly reduces the manual effort of writing package recipes.

4. Switching to Readily Available Alternatives

Sometimes the simplest path is adopting software that is already a first-class citizen in Guix. Rather than maintaining a custom Caddy setup with third-party plugins, I switched back to Nginx combined with Certbot. Both are well-supported native services in Guix System, simplifying long-term maintenance.

Conclusion

It has been a month since migrating my home server to GNU Guix. Managing OS state declaratively through Git has eliminated configuration drift, and Guile Scheme provides a cohesive environment that complements Emacs. While adapting to a smaller package ecosystem and managing substitute timing requires occasional adjustments, the stability, reproducibility, and container isolation make it a dependable foundation.

In my free time, I have started reading the legendary SICP (Structure and Interpretation of Computer Programs) to deepen my understanding of Scheme and functional programming.