I’ll be honest - when I first heard about GNU Guix, it sounded intimidating. Another package manager? With a functional programming twist? And what’s all this about “time machines” and “channels”?
But as it turns out, Guix solves a problem I’ve been wrestling with for years: how do you ensure that your development environment works exactly the same way across different machines and points in time? You know the drill - you set up a project on your laptop, it works perfectly, then six months later you try to run it on your server and nothing works because package versions have changed, dependencies have shifted, and you’re stuck playing detective to figure out what broke.
I spent some time recently setting up Guix for a personal blog project (this one, actually), and I was surprised by how straightforward it became once I understood the core concepts. Think about it like this: if Docker gives you reproducible containers, Guix gives you reproducible environments - but without the overhead and with much more granular control.
In this post, I’ll walk you through everything I learned about using Guix for per-project reproducible environments. We’ll cover the key concepts (channels, manifests, and time-machine), show you how to integrate it with direnv for automatic environment activation, and go through real examples that you can adapt for your own projects.
Before we dive into the how, let’s talk about the why. If you’re like me, you’ve probably tried various approaches to managing development dependencies:
Guix takes a different approach. Instead of managing containers or language-specific environments, it manages packages at the system level with a functional approach. This means:
Now, I won’t lie to you - there’s a learning curve. But once you get the hang of it, it’s incredibly powerful. Let me show you how it works.
The first concept to wrap your head around is channels. Think of channels as package repositories, but with a twist - they’re Git repositories that contain package definitions, and you can pin them to specific commits for reproducibility.
Guix comes with a default channel (the main GNU Guix repository), but you’ll likely want to add others. The most common addition is the “nonguix” channel, which provides packages that can’t be included in the main Guix repository due to licensing restrictions (like proprietary software or packages with non-free dependencies).
Here’s what a basic channels.scm
file looks like:
;; Guix channels pinned for reproducibility
(list (channel
(name 'nonguix)
(url "https://gitlab.com/nonguix/nonguix")
(branch "master")
(commit "a96e2451bda5aaf9b48339edee392c6a3017d730")
(introduction
(make-channel-introduction
"897c1a470da759236cc11798f4e0a5f7d4d59fbc"
(openpgp-fingerprint
"2A39 3FFF 68F4 EF7A 3D29 12AF 6F51 20A0 22FB B2D5"))))
(channel
(name 'guix)
(url "https://git.savannah.gnu.org/git/guix.git")
(branch "master")
(commit "80651b889926a304a671092ad2fc223440845a70")
(introduction
(make-channel-introduction
"9edb3f66fd807b096b48283debdcddccfea34bad"
(openpgp-fingerprint
"BBB0 2DDF 2CEA F6A8 0D1D E643 A2A0 6DF2 A33A 54FA")))))
This might look intimidating at first, but it’s actually quite straightforward. Each channel entry specifies:
The beauty of this approach is that you’re not just saying “use the latest version of nonguix” - you’re saying “use exactly this commit of nonguix.” This means that months or years from now, you can recreate the exact same package set.
You don’t have to write these files by hand. Guix provides a handy command to snapshot your current channel configuration:
guix describe --format=channels > channels.scm
This captures whatever channels you currently have configured along with their exact commit hashes. It’s like taking a snapshot of your package universe at a specific point in time.
Now that you understand channels, let’s talk about manifests. If channels define where packages come from, manifests define which packages you actually want. Think of a manifest as your project’s bill of materials - a list of everything needed to recreate your development environment.
Here’s a simple manifest file:
;; Simple manifest for a Python data science project
(specifications->manifest
'("python-wrapper" ; Provides 'python' command (not just 'python3')
"python-numpy"
"python-pandas"
"python-matplotlib"
"jupyter"
"python-ipython"
"python-notebook"
"python-nbconvert"
"git"
"make"))
As it turns out, this approach is much more explicit than language-specific dependency files. Instead of relying on a requirements.txt
that might resolve differently depending on when you install it, you’re specifying exactly which packages you need from the Guix ecosystem.
For my blog setup, I needed Hugo (a specific old version that works with my theme), Python for data analysis, and Jupyter for notebooks. Here’s what my manifest looks like:
;; Blog development environment
(specifications->manifest
'("python-wrapper"
"python-numpy"
"python-pandas"
"python-matplotlib"
"jupyter"
"python-ipython"
"python-ipykernel"
"python-notebook"
"python-nbconvert"
"python-nbformat"
"python-attrs"
"python-bleach"
"python-jinja2"
"python-jsonschema"
"python-markupsafe"
"python-pygments"
"python-pytz"
"python-six"
"python-tornado"))
You might notice that Hugo isn’t in this list. That’s because the version of Hugo available in the current Guix channels (v0.140.2) breaks my blog theme, which only works with versions before 0.120.0. This brings us to our next topic: getting specific package versions.
Here’s where Guix really shines. Most package managers give you limited options for getting older versions of packages. You might find a few old versions in the repository, but good luck getting that specific version from six months ago that you know works with your project.
Guix’s time-machine feature lets you travel back to any point in Guix’s history and install packages from that era. It’s like having access to every version of every package that was ever available.
The basic idea is simple: instead of using packages from the current Guix channels, you use packages from Guix channels as they existed at a specific point in time. Here’s how you’d use it:
# Use packages from a specific commit
guix time-machine --channels=channels.scm -- environment --manifest=manifest.scm
This command does several things:
channels.scm
manifest.scm
Now, I’ll be honest - time-machine isn’t always smooth sailing. During my blog setup, I ran into issues with old channel commits that referenced packages or dependencies that no longer existed. It’s particularly tricky with the nonguix channel, which sometimes has compatibility issues with older Guix commits.
But here’s where Guix’s flexibility really shines: you can define custom packages directly in your manifest. Instead of relying on historical channels or manual downloads, I looked at how Hugo is packaged in the nonguix repository (https://gitlab.com/nonguix/nonguix/-/blob/master/nongnu/packages/hugo.scm) and adapted it for my needs.
The key insight is that Hugo is a single Go binary with no complex dependencies which is perfect for a custom package definition. Here’s what my updated manifest looks like:
;; Blog development environment with custom Hugo v0.100.0
(use-modules (gnu)
(guix packages)
(guix profiles)
(guix download)
(guix licenses)
(nonguix build-system binary))
;; Define Hugo v0.100.0 locally
(define hugo-0.100.0
(package
(name "hugo")
(version "0.100.0")
(source
(origin
(method url-fetch)
(uri (string-append
"https://github.com/gohugoio/hugo/releases/download/v" version
"/hugo_extended_" version "_Linux-64bit.tar.gz"))
(sha256
(base32 "1rijpir80vx2hxkmgmzg2ac784bbh78vf0gcfyqm4la6qca48qln"))))
(build-system binary-build-system)
(arguments
`(#:install-plan '(("hugo" "/bin/hugo"))
#:validate-runpath? #f)) ; Skip validation for binary package
(home-page "https://gohugo.io/")
(synopsis "Static site generator written in Go (version 0.100.0)")
(description "Hugo v0.100.0 for compatibility with older themes.")
(license asl2.0)))
;; Create manifest with both Hugo and Python packages
(packages->manifest
(append
(list hugo-0.100.0)
(map specification->package
'("python-wrapper"
"python-numpy"
"python-pandas"
; ... other packages
))))
This approach gives you the best of both worlds: proper Guix package management with exact version control. Since Hugo releases are just single binaries with predictable URLs, it’s straightforward to create package definitions for any version you need.
All of this is great, but you don’t want to manually activate your environment every time you enter your project directory. This is where direnv comes in - it’s a tool that automatically loads and unloads environment variables based on your current directory.
Direnv has built-in support for Guix, making it incredibly easy to set up per-project environments that activate automatically when you cd
into your project directory.
The simplest setup uses just a manifest file. Create a .envrc
file in your project root:
use guix --manifest=manifest.scm
When you run direnv allow
, direnv will automatically run guix shell --manifest=manifest.scm
whenever you enter the directory, and clean up the environment when you leave.
For more complex setups that use time-machine, you can call it directly in your .envrc
:
# Use time-machine with pinned channels for reproducibility
eval "$(guix time-machine --channels=channels.scm -- environment --manifest=manifest.scm --search-paths)"
That’s it! Since our Hugo package is defined directly in the manifest, everything gets handled automatically by Guix. No manual scripts, no PATH manipulation - just pure, reproducible package management.
This setup does everything automatically when you enter the directory:
Once you have this set up, the development experience is seamless. Here’s what it looks like in practice:
# Outside the project directory
$ which python
/usr/bin/python3
# Enter the project directory
$ cd my-blog-project
direnv: loading ~/my-blog-project/.envrc
Computing Guix derivation for 'x86_64-linux'...
# Now you have the project environment
$ which python
/gnu/store/...-python-wrapper-3.10.7/bin/python
$ python --version
Python 3.10.7
$ hugo version
hugo v0.100.0-27b077544d8efeb85867cb4cfb941747d104f765 linux/amd64
# Leave the project directory
$ cd ..
direnv: unloading
# Back to system packages
$ which python
/usr/bin/python3
It’s incredibly smooth once you get it working. No more “wait, which Python version am I using?” or “did I remember to activate my virtual environment?”
Now that we’ve covered the core concepts, let me share some patterns I’ve found useful for different types of projects.
For many projects, you don’t need historical packages - you just want isolation from your system packages and the ability to reproduce the environment later. This is the simplest approach:
;; manifest.scm
(specifications->manifest
'("python-wrapper"
"nodejs"
"git"
"make"))
# .envrc
use guix --manifest=manifest.scm
This gives you a clean, isolated environment with the packages you need, and you can commit both files to version control for your teammates.
When you need to ensure that your environment stays exactly the same over time, use the pinned channel approach:
guix describe --format=channels > channels.scm
eval "$(guix time-machine --channels=channels.scm -- environment --manifest=manifest.scm --search-paths)"
Sometimes you need packages that aren’t available in Guix, or you need specific versions that are problematic with time-machine. In these cases, you can combine Guix-managed packages with manually installed ones:
# .envrc
use guix --manifest=manifest.scm
# Install specific tools locally
./install-custom-tools.sh
# Add local tools to PATH
PATH_add .local/bin
This approach lets you get the benefits of Guix for most packages while handling edge cases manually.
For different programming languages, you’ll want to adjust your approach:
Python Projects:
python-wrapper
to get the python
command (not just python3
)python-pip
if you need to install additional packages with pippython-virtualenv
if you want to use virtual environments on top of GuixNode.js Projects:
nodejs
and npm
in your manifestnode-lts
for long-term support versionsnode-typescript
if you use TypeScriptGo Projects:
go
in your manifestgo-tools
for additional development toolsAs you start using Guix for development environments, you’ll likely run into some common issues. Here are the ones I encountered and how to solve them:
If Guix can’t find a package you’re looking for:
guix search package-name
python-
prefixesWhen time-machine fails to build historical environments:
If direnv isn’t activating your environment:
direnv allow
in the project directorydirenv reload
will show you what’s happening.envrc
manually by running the commands directlyIf your environment takes a long time to load:
After working with Guix environments for a while, here are some practices I’ve found helpful:
Start with just the packages you actually need. You can always add more later, but it’s easier to debug a simple manifest than a complex one.
Always commit your manifest.scm
, channels.scm
, and .envrc
files to version control. This ensures that anyone who clones your project can recreate your exact environment.
Include a README section that explains how to get started with your Guix environment:
## Development Environment
This project uses GNU Guix for reproducible development environments.
1. Install GNU Guix and direnv
2. Clone this repository
3. Run `direnv allow` in the project directory
4. The environment will automatically activate when you enter the directory
If possible, test your Guix environment on different machines to make sure it’s truly reproducible. This is especially important if you’re collaborating with others.
When you have a working environment, consider creating a backup of your channels.scm
file. If you update your channels and something breaks, you can easily roll back to a known-good state.
If you frequently use the same set of packages across multiple projects, consider creating a Guix profile for them:
guix install --profile=~/.guix-profiles/data-science python-pandas python-numpy jupyter
Then you can activate this profile when needed without recreating the environment each time.
It’s worth putting Guix in context with other approaches you might be familiar with:
Docker pros:
Guix pros:
Guix and Nix are actually quite similar - both use functional package management principles. The main differences:
Nix:
Guix:
Language-specific tools (venv, rbenv, etc.):
Guix:
I’ll admit, when I started this journey, I wasn’t sure if Guix was worth the learning curve. But now that I have it set up, I’m really impressed with what it provides. The ability to pin exact package versions and recreate environments months or years later is incredibly valuable, especially for long-term projects or research that needs to be reproducible.
The integration with direnv makes the day-to-day experience seamless - I don’t have to think about activating environments or managing dependencies manually. When I enter a project directory, everything just works, with exactly the package versions that project needs.
Is Guix right for every project? Probably not. If you’re working on a simple web app that uses standard, current versions of everything, the traditional approaches (Docker, language-specific tools) might be simpler. But if you’re working on projects where reproducibility matters - research, long-term systems, or anything that needs to work consistently across time and machines - Guix is worth considering.
The key things to remember:
All in all, I’m glad I took the time to learn Guix. It’s not the easiest tool to get started with, but once you understand the concepts, it provides a level of control and reproducibility that’s hard to match with other approaches. And now that I have a working setup, creating new reproducible environments is actually quite straightforward.
If you’re dealing with “works on my machine” problems or need to ensure your development environment stays consistent over time, give Guix a try. Just be prepared for a bit of a learning curve - but I think you’ll find it’s worth it.