DEV Community

Cover image for An Introduction to Nix for Ruby Developers
Martin Streicher for AppSignal

Posted on • Originally published at blog.appsignal.com

An Introduction to Nix for Ruby Developers

A predictable, stable environment (in terms of your operating system, system libraries, build tools, and programming libraries) is essential to each development step: from onboarding, to collaboration, continuous integration, quality assurance, and deployment. Deviation can cause one-off, intermittent, and even catastrophic failures.

However, consistency can be elusive, even with the best intentions, best practices, and tools in place, because:

  • Developer machines are easily polluted with clashing libraries and binaries.
  • Programming libraries — Ruby's gems or Python's eggs, for example — are regularly updated on unadvertised and unpredictable schedules.
  • Docker images can be removed from public repositories without notice.
  • Continuous integration providers change internals without forewarning or ex post facto notice.

Nix aims to solve some of these issues. Specifically, it is designed to reproduce packages and environments verbatim. Given a set of inputs, it generates the same output every time.

Let's explore how Nix can reproduce an environment for Ruby and Rails
development, including a version of Ruby, a collection of gems, a PostgreSQL database, and a Redis cache.

An Overview of Nix

Nix is an entire universe of software. It runs on Linux and macOS, on both Apple Silicon and Intel processors, and has several components:

  • The Nix Programming Language is a declarative, domain-specific, functional language dedicated to system composition. Portions of the language manipulate Nix directly, while others provide typical programming constructs like flow control and variables.
  • Nix Packages is an expansive repository of software, from awk to zsh. At the time of writing, the repository contains more than 100,000 entries, each usable with Nix. Each package in the Nix archive explicitly documents all its dependencies down to a specific commit. Further, the nixpkgs archive is itself versioned, providing even greater control over package provenance.
  • nix-shell provides a virtual, sacrosanct environment contained in a shell. Given a Nix description of a system — a derivation in Nix parlance — you can compose and boot a system in a walled garden isolated from all other virtual and physical systems. For example, you can download, install, build, and run an independent instance of Ruby with nix-shell.
  • The Nix Store persists immutable data (such as software packages) and all dependencies. On some systems, the Nix Store is the local file system; however, it can also be retained on Amazon S3 and even a remote SSH server.
  • NixOS is an entire Linux distribution configured entirely by the Nix language and composed from versions in Nixpkgs.

Nix is expansive and each component warrants deep exploration. This
introduction focuses on the nix-shell and Nix Packages and presents
just enough of the Nix programming language to boot a Rails application. Tony Finn's Nix from First Principles
provides another take on Nix.

Nix Versus Docker

Docker and Nix are both capable of building environments. However, Nix provides consistency everywhere, including build tools and source packages.

Docker is not a package manager. While you can connect and coordinate
containers using Docker Compose, you cannot use Docker to combine containers into a new container.

It's trivial with Nix to combine disparate Ruby and PostgreSQL derivations into a new environment.

If a Docker image changes without notice, the output of docker build changes too. Pinning a version obviously helps, but if a
Docker image is deleted or an image's repository goes offline, a
Docker build fails without recourse.

Each package and version stored by Nix can have a unique ID akin to a git SHA. You can think of a complete Nix derivation as a manifest of tens or hundreds of SHAs. A change in any one SHA is detectable and two derivations will not be the same if a single entity has changed.

Get Started with Nix

Let's dive into Nix.

To install Nix natively on Apple MacOS, open the Terminal application in Applications/Utilities and run the following command at the shell prompt:

# Shell command to download and install Nix on your system
curl --proto '=https' --tlsv1.2 -sSf \
  -L https://install.determinate.systems/nix \
  | sh -s -- install
Enter fullscreen mode Exit fullscreen mode

Enter the password for the root user of your system when prompted.
(If you are a Mac administrator, you can enter your password too.) You need privileged access as the install creates a new storage volume specifically for Nix and tweaks several system settings, such as excluding the Nix store from Time Machine backups.

The installer summarizes all the modifications it plans to perform and prompts you to proceed. Enter Yes.

# Nix output
Nix install plan (v0.18.0)
Planner: macos (with default settings)

Planned actions:
* Create an encrypted APFS volume `Nix Store` for Nix on `disk1` and add it to `/etc/fstab` mounting on `/nix`
* Fetch `https://releases.nixos.org/nix/nix-2.21.2/nix-2.21.2-x86_64-darwin.tar.xz` to `/nix/temp-install-dir`
* Create a directory tree in `/nix`
* Move the downloaded Nix into `/nix`
* Create build users (UID 301-332) and group (GID 30000)
* Configure Time Machine exclusions
* Setup the default Nix profile
* Place the Nix configuration in `/etc/nix/nix.conf`
* Configure the shell profiles
* Configuring zsh to support using Nix in non-interactive shells
* Create a `launchctl` plist to put Nix into your PATH
* Configure Nix daemon related settings with launchctl
* Remove directory `/nix/temp-install-dir`

Proceed? ([Y]es/[n]o/[e]xplain): Y

 INFO Step: Create an encrypted APFS volume `Nix Store` for Nix on `disk1` and add it to `/etc/fstab` mounting on `/nix`
 INFO Step: Provision Nix
 INFO Step: Create build users (UID 301-332) and group (GID 30000)
 INFO Step: Configure Time Machine exclusions
 INFO Step: Configure Nix
 INFO Step: Configuring zsh to support using Nix in non-interactive shells
 INFO Step: Create a `launchctl` plist to put Nix into your PATH
 INFO Step: Configure Nix daemon related settings with launchctl
 INFO Step: Remove directory `/nix/temp-install-dir`

Nix was installed successfully! To get started using Nix, open a new shell or run `. /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh`
Enter fullscreen mode Exit fullscreen mode

The installation takes only a moment or two to finish.

You can easily test it when it's done by running the following sequence of commands in your current shell.

# Load Nix into the current shell environment
source /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh
Enter fullscreen mode Exit fullscreen mode
# Display the free space found on the new volume at /nix
df /nix

# Filesystem   512-blocks      Used Available Capacity iused      ifree %iused  Mounted on
# /dev/disk1s7  976490576    799416 228759728     1%   73715 1143798640    0%   /nix
Enter fullscreen mode Exit fullscreen mode
# Use Nix to install and run the `hello` command
nix run 'nixpkgs#hello'
# Produces `Hello, World!'
Enter fullscreen mode Exit fullscreen mode

The nix run command is something akin to magic. To run the hello utility, Nix:

  • Downloads all the packages required to build hello, including tools such as make, gcc, and tar. (On the machine used to develop this article, an i9 iMac running MacOS Sonoma, Nix downloaded more than 400 prerequisite packages into /nix/store.)
  • Downloads the most recent version of hello (as no version number was specified).
  • Builds and executes the utility, producing Hello, World!.
  • Terminates and returns to the original shell.

If you run the same command — nix run 'nixpkgs#hello' — again, Nix executes hello immediately, as the assets required for the utility are cached and readily accessible.

You can use Nix to run almost any utility on-demand. Here is another example.

# Launch the `fish` shell (see https://fishshell.com)
nix run `nixpkgs#fish`
martin@iMac ~/p/o/a/martin (ruby-on-nix)>
Enter fullscreen mode Exit fullscreen mode

The penultimate line of the output above is the default fish prompt, showing a user name, machine name, an abbreviated current working directory name, and the current git branch, if any. Press Control-D to exit the shell.

If you're following along, try this: Launch fish via Nix and enter the command which hello. The result should look like this:

strike@iMac ~/p/o/a/martin (ruby-on-nix)> which hello
strike@iMac ~/p/o/a/martin (ruby-on-nix) [1]>
Enter fullscreen mode Exit fullscreen mode

which hello fails in the current environment. ([1] is the return value of the previous command, not the output of the previous command. Shell utilities return non-zero values on failure.) Where is the hello from the previous install? It's in its own encapsulated derivation, completely isolated from the one created for fish.

A one-off Nix derivation such as the one above has its uses, but a Nix derivation is not limited to a sole utility. A derivation may contain any number of packages. Better yet, a derivation can be declared, shared, and re-instantiated in identical form on any machine.

Nix for Ruby Development

Let's create a Nix derivation for Rails development. The derivation must have:

  • A version of Ruby, such as 3.2.0 (the latest version of Ruby available via Nix at the time of writing). Each Ruby package includes gem and irb.
  • A database service to persist information. Here, let's use PostgreSQL Version 15.
  • A key store for caching data, partials, and views. Redis is quite capable.
  • Tools for JavaScript and asset compilation.

Of course, a plenary Rails environment also needs the rails gem and many others. Ideally, a Nix derivation for Rails development also automatically bundles necessary gems, producing a standalone environment ready for development.

A Nix Derivation for Ruby Development

The code below is a nascent, but working, Nix derivation for Rails development. A Nix derivation is like a Dockerfile or a blueprint to build a working environment from scratch.

let
  nixpkgs =
    import (builtins.fetchTarball {
      url = https://github.com/NixOS/nixpkgs/archive/ee4a6e0f566fe5ec79968c57a9c2c3c25f2cf41d.tar.gz;
    }) { };

  targetRuby = nixpkgs.ruby_3_2;

  myBundler = nixpkgs.bundler.override {
    ruby = targetRuby;
  };

  gems =
    nixpkgs.bundlerEnv {
      inherit (nixpkgs) ruby_3_2;

      name     = "rails-gems";
      bundler  = myBundler;
      gemfile  = ./Gemfile;
      lockfile = ./Gemfile.lock;
      gemset   = ./gemset.nix;
    };

in
  nixpkgs.mkShell {
    buildInputs = [
      targetRuby
      gems
      gems.wrappedRuby
      nixpkgs.bundler
      nixpkgs.bundix
      nixpkgs.nodejs
      nixpkgs.yarn
      nixpkgs.postgresql
    ];
  }
Enter fullscreen mode Exit fullscreen mode

The sample derivation has two sections. The let section defines settings, while the in portion controls the build and enumerates the packages to construct and install in the derivation.

Before discussing how to use the derivation, some commentary:

  • The setting nixpkgs requires a specific version of the Nix archive. Like a git repository, each iteration of the Nix packages archive is represented by a unique SHA reflecting the state of its contents. If the contents of the archive change, such as by incorporating a new version of Ruby, the SHA changes in tandem. Similar to referencing specific commits in git, you can lock your derivation to a specific iteration of the archive. The particular archive referred to here, ee4a6e0, contained a bug fix required to build the example derivation.
  • The setting targetRuby specifies a Ruby version. Here, the derivation uses the latest version of Ruby 3.2 available in the archive.
  • The setting myBundler customizes the Nix build to use the same version of Ruby specified for the shell environment.
  • Lastly, the setting named gems configures the Nix version of bundler.

As with native bundler, the Nix bundler requires a Gemfile and a Gemfile.lock. gemset.nix is original to Nix and is a translation of Gemfile.lock to the Nix syntax.

If you want to know what versions of Ruby are available, point your browser to the Nix Packages Index and search for ruby. The search results include all the Ruby iterations available, such as ruby, ruby_3_2, and ruby_3_3, for Ruby 3.1, 3.2, and 3.3, respectively. Each package in the search result specifies the exact version number, such as 3.1.4 for the ruby package.

If you want to use a specific version of Ruby that is not named in the archive, try nixpkgs-ruby. For instance, to use Ruby 3.2.2, replace the line targetRuby = nixpkgs.ruby_3_2; in shell.nix with this code:

nixpkgs-ruby =
  import (builtins.fetchTarball {
    url = "https://github.com/bobvanderlinden/nixpkgs-ruby/archive/c1ba161adf31119cfdbb24489766a7bcd4dbe881.tar.gz";
  });

targetRuby = nixpkgs-ruby.packages.x86_64-linux."ruby-3.2.2";
Enter fullscreen mode Exit fullscreen mode

Running the Nix Shell

Using the Nix derivation for Rails development proceeds much like development in any shell. The big exception: There is no need to install Ruby or attendant services beforehand. If you have Nix installed, you're ready to go.

  1. Install Nix if necessary.
  2. Clone a Rails project into a new directory or choose an existing codebase. cd to the directory for the project.
  3. Copy and paste the code for the sample derivation to a new file in the project directory named shell.nix. (shell.nix is the customary name for a derivation, but the name is otherwise arbitrary.)
  4. Generate gemset.nix to catalog the gems used within the project.
   nix \
     --extra-experimental-features nix-command \
     --extra-experimental-features flakes \
     run 'nixpkgs/nixos-unstable#bundix' -- --lock
Enter fullscreen mode Exit fullscreen mode

This command runs bundix, a Nix program, to convert Gemfile.lock_into _gemset.nix. Both are gem manifests, albeit the latter expressed natively in the Nix language. For comparison, here is an entry for actioncable from gemset.nix and from Gemfile.lock.

   # From gemset.nix
   #
   actioncable = {
     dependencies = ["actionpack" "activesupport" "nio4r" "websocket-driver" "zeitwerk"];
     groups = ["default"];
     platforms = [];
     source = {
       remotes = ["https://rubygems.org"];
       sha256 = "0ifiz4nd6a34z2n8lpdgvlgwziy2g364b0xzghiqd3inji0cwqp1";
       type = "gem";
     };
     version = "7.1.3.2";
   };

   ## From Gemfile.lock
   #
   actioncable (7.1.3.2)
     actionpack (= 7.1.3.2)
     activesupport (= 7.1.3.2)
     nio4r (~> 2.0)
     websocket-driver (>= 0.6.1)
     zeitwerk (~> 2.6)

Enter fullscreen mode Exit fullscreen mode
  1. Generate and launch the derivation.
nix-shell --verbose
Enter fullscreen mode Exit fullscreen mode

The --verbose option is not required, but provides good and numerous insights into how nix-shell assembles the environment.

The Downsides: Nix has Some Flaws

Nix is a rich, robust, and burgeoning option for building, reproducing, and sharing environments. Nix is also an emergent tool: its current users are early adopters.

Nix is already a workable solution for many problems, but Ruby support seems to be a work in progress. For example, at the time of writing, no official archive is available for newer versions of Ruby 3.3, and there is no guarantee the latest Nix archive contains every gem in your Gemfile. Documentation for Ruby on Nix is scattered between various GitHub repos, the official Nix website, and user forums. I am grateful to a number of intrepid Nix developers who helped me craft the sample shell.nix, including Evan Travers, Bob van der Linden, and Norbert Melzer.

Nix currently is akin to git's "porcelain": powerful but esoteric. However, much like git evolved into exoteric, user-friendly tools such as git-flow, GitHub Desktop, and Tower to become user-friendly, many developers are building abstractions, wrappers, and utilities to simplify Nix usage. Let's briefly look at a few of these tools now.

Tools to Help with Nix: devbox, devenv.sh, and fleek

A tool to experiment with is devbox. devbox turned a 10-year-old Macbook Pro laptop running macOS Big Sur into a development machine for a modern Rails application in a few minutes. The setup did not include automatic bundling of gems, but it did install Ruby (including bundler and gem), Redis, and PostgreSQL in an isolated and pristine shell environment. devbox eschews a bespoke programming language for configuration and uses JSON. The environment on the MacBook Pro booted from this minimal file named devbox.json:

{
  "$schema": "https://raw.githubusercontent.com/jetify-com/devbox/0.10.6/.schema/devbox.schema.json",
  "packages": [
    "postgresql@latest",
    "redis@latest",
    "ruby@3.2.2",
    "libsass@latest"
  ]
}
Enter fullscreen mode Exit fullscreen mode

devenv.sh merits exploration too. It is something of a hybrid, with a JSON-like programming language, YAML configuration, and Docker-like composition of services.

fleek is another Nix-based tool to craft and share configurations for development machines. It also avoids the Nix programming language and instead provides Homebrew-like commands to build an environment.

Wrapping Up

In this post, we've seen how Nix can help reproduce a stable environment for Ruby and Rails applications.

While it has some flaws, Nix and the myriad offshoots in development are worth watching.

Go forth and hack!

P.S. If you'd like to read Ruby Magic posts as soon as they get off the press, subscribe to our Ruby Magic newsletter and never miss a single post!

Top comments (0)