4  Reproducible Development Environments with rix

4.1 Introduction

Now that we have Nix installed, and our IDE configured we can actually tackle the reproducibility puzzle.

4.1.1 The Reproducibility Challenge

Reproducibility in research and data science exists on a continuum. At one end, authors might only describe their methods in prose. Moving along the spectrum, they might share code, then data, and finally what we call a computational environment: the complete set of software required to execute an analysis.

Even when researchers share code and data, they rarely specify the full software stack: the exact version of R, all package versions, and crucially, the system-level dependencies. Yet differences in any of these can lead to divergent results from the same code.

Tools like {renv} address part of the puzzle: they capture R package versions in a lockfile. But {renv} does not manage the R version itself (you need rig for that), and neither handles system libraries. If {sf} requires GDAL 3.0 but your system has 2.4, {renv} can’t help. And if your project uses both R and Python? Now you’re coordinating multiple package managers, each with its own configuration.

4.1.2 Component Closures: The Nix Approach

This is where Nix shines. Nix deploys component closures: when you install a package, Nix also installs all its dependencies, their dependencies, and so on. Think of it like packing for a trip—traditional package managers assume you’ll find essentials at your destination, while Nix packs everything you need.

As the original Nix paper explains:

The idea is to always deploy component closures: if we deploy a component, then we must also deploy its dependencies, their dependencies, and so on. Since closures are self-contained, they are the units of complete software deployment.

This means when you install {sf} through Nix, you automatically get the correct versions of GDAL, GEOS, and PROJ—no manual system configuration needed.

4.1.3 The Polyglot Challenge

Modern data science is increasingly polyglot. Research shows that data scientists use, on average, nearly two programming languages in their work, with R and Python being the most common combination. Python dominates machine learning, R excels at statistical modelling, and Julia offers high-performance numerics. Projects increasingly combine these strengths.

This creates a reproducibility challenge: a project using R, Python, and Quarto requires coordinating multiple package managers. Nix solves this by providing a unified framework for all languages and system tools.

4.1.4 Enter rix

However, Nix has a steep learning curve. Its functional programming language can be daunting for researchers focused on their analysis, not system administration.

{rix} bridges this gap. It’s an R package that generates Nix expressions from intuitive R function calls. You describe what you want, and {rix} figures out how to express it in Nix. The workflow is simple:

  1. Declare your environment using rix()
  2. Build the environment using nix-build
  3. Use the environment using nix-shell

This chapter covers everything you need to know to create project-specific, reproducible development environments for your R projects.

4.2 Transitioning to Nix-Managed R

Now that Nix is installed, I strongly recommend uninstalling any system-wide R installation and removing the packages in your user library (typically found in ~/R on Linux or ~/Library/R on macOS). From this point forward, let Nix handle everything. If you are using Windows, you can keep your Windows-specific R installation, since Nix will not interfere with it (remember, Nix is installed inside WSL and Positron will automatically load Nix environments installed in WSL as well).

If you are not ready to take this step, you can continue using your local R library. The {rix} package makes efforts not to interfere with your existing setup, and the .Rprofile it generates helps keep Nix environments isolated. However, for the cleanest experience and to avoid subtle conflicts, I recommend fully committing to Nix.

4.2.1 Bootstrapping {rix} without a local R installation

But if R is uninstalled, how do you use {rix} to generate environments? The answer lies in the temporary shells we saw in the previous chapter. Use Nix itself to bootstrap a temporary R session with {rix} available.

Running the following line in a terminal will drop you into an interactive R session:

nix-shell -p R rPackages.rix

This gives you a temporary shell with R and {rix} ready to use. From here, you can generate project-specific Nix expressions.

For example, navigate to an empty directory for a new project:

mkdir my-project
cd my-project

Then start R:

R

Load {rix} and generate an expression:

library(rix)

rix(
  date = "2025-04-11",
  r_pkgs = c("dplyr", "ggplot2"),
  ide = "none",
  project_path = ".",
  overwrite = TRUE
)

This writes a default.nix file to your project directory. The expression defines a shell with R, {dplyr}, and {ggplot2} as they were on the 11th of April 2025 on CRAN. The ide argument is set to "none" because we configured Positron in the previous chapter rather than having Nix manage an IDE.

I recommend saving this code in a file called gen-env.R in your project directory. If you later need to add packages or change the R version, simply edit gen-env.R, run it to regenerate default.nix, and rebuild with nix-build. This keeps your environment definition readable and versioned alongside your project.

Getting LLM assistance with {rix}

If the {rix} syntax is new to you, remember that you can use pkgctx to generate LLM-ready context (as mentioned in the introduction). The {rix} repository includes a .pkgctx.yaml file you can feed to your LLM to help it understand the package’s API. You can also generate your own context file:

nix run github:b-rodrigues/pkgctx -- r github:ropensci/rix > rix.pkgctx.yaml

With this context, your LLM can help you write correct rix() calls, even if you’ve never used the package before. You can do so for any package hosted on CRAN, GitHub, or local .tar.gz files.

You can now exit this temporary session (type q() in R, then exit in the shell) and build your new environment:

nix-build

Once built, use nix-shell to enter your project’s environment. This workflow of bootstrapping {rix} via a temporary shell, generating a default.nix, and then building it is the pattern you will follow for every new project.

This is also the reason why there is no Python version of {rix}: you can bootstrap a Python environment using {rix} in the same way, by only temporarily having the R interpreter available through the Nix shell.

4.3 The rix() Function

The rix() function is the heart of the package. It generates a default.nix file—a Nix expression that defines your development environment. Here are its main arguments:

  • r_ver: the version of R you need (or use date instead)
  • date: a date corresponding to a CRAN snapshot
  • r_pkgs: R packages to install from CRAN/Bioconductor
  • system_pkgs: system tools like quarto or git
  • git_pkgs: R packages to install from GitHub
  • local_r_pkgs: local .tar.gz packages to install
  • tex_pkgs: TexLive packages for literate programming
  • ide: which IDE to configure ("rstudio", "code", "positron", "none"). Since we’ve configured Positron in the previous chapter, you’ll want to set this to "none". If you want to use RStudio, then set it to "rstudio". This will install RStudio using Nix and make it available from the development shell. If you set it to "code" or "positron", this will then install VS Code or Positron using Nix.
  • project_path: where to save the default.nix file
  • overwrite: whether to overwrite an existing default.nix

Let’s create our first environment. Suppose you need R with {dplyr} and {ggplot2}:

library(rix)

rix(
  r_ver = "4.4.2",
  r_pkgs = c("dplyr", "ggplot2"),
  ide = "rstudio",
  project_path = ".",
  overwrite = TRUE
)

This generates two files:

  1. default.nix: The Nix expression defining your environment
  2. .Rprofile: Created by rix_init() (called automatically), this file prevents conflicts with any system-installed R packages

The .Rprofile is important: it ensures that packages from your user library don’t get loaded into the Nix environment, and it redefines install.packages() to throw an error, because you should never install packages that way in a Nix environment.

4.4 Choosing an R Version or Date

The r_ver argument (or alternatively date) controls which version of R and which package versions you’ll get. Here’s a summary of the options:

r_ver / date Intended Use R Version Package Versions
"latest-upstream" New project, versions don’t matter Current/previous Up to 6 months old
"4.4.2" (or similar) Reproduce old project or start new Specified version Up to 2 months old
date = "2024-12-14" Precise CRAN snapshot Current at that date Exact versions from date
"bleeding-edge" Develop against latest CRAN Always current Always current
"frozen-edge" Latest CRAN, manual updates Current at generation Current at generation
"r-devel" Test against R development version R-devel Always current

To see which R versions are available:

available_r()

To see which dates are available for snapshotting:

available_dates()

4.4.1 Using dates vs versions

Using a specific date is often the best choice for reproducibility. When you specify a date, you get the exact state of CRAN on that day:

rix(
  date = "2024-12-14",
  r_pkgs = c("dplyr", "ggplot2"),
  ide = "none",
  project_path = ".",
  overwrite = TRUE
)

I find that this is often easier and clearer than using an R version. Be careful though, as using a date doesn’t reflect the state of PyPi on that date. So if you also need Python packages, the versions of packages for Python that will be provided are the ones available from nixpkgs on that day (but more on this later).

4.4.2 The rstats-on-nix fork

When you use a specific R version or date (rather than "latest-upstream"), {rix} uses our rstats-on-nix fork of nixpkgs rather than the upstream repository. This fork:

  • Snapshots CRAN more frequently
  • Offers newer R releases faster than the official channels
  • Includes many fixes, especially for Apple Silicon (even though as time goes by, this is becoming much rarer, because Nix is getting better and more support for Apple Silicon)

4.5 Installing R Packages

4.5.1 From CRAN/Bioconductor

The simplest case—just list package names in r_pkgs:

rix(
  r_ver = "4.4.2",
  r_pkgs = c("dplyr", "ggplot2", "tidyr", "readr"),
  ide = "none",
  project_path = "."
)

Both CRAN and Bioconductor packages can be specified this way.

4.5.2 Installing archived versions

Need a specific old version of a package? Use the @ syntax:

rix(
  r_ver = "4.2.1",
  r_pkgs = c("dplyr@0.8.0", "janitor@1.0.0"),
  ide = "none",
  project_path = "."
)

This will install {dplyr} version 0.8.0 from the CRAN archives. Note that archived packages are built from source, which may fail for packages requiring compilation. Thus I recommend you use a date on which that specific version of {dplyr} was current.

4.5.3 Installing from GitHub

For packages on GitHub, use the git_pkgs argument with a list containing the package name, repository URL, and commit hash:

rix(
  r_ver = "4.4.2",
  r_pkgs = c("dplyr", "ggplot2"),
  git_pkgs = list(
    list(
      package_name = "housing",
      repo_url = "https://github.com/rap4all/housing/",
      commit = "1c860959310b80e67c41f7bbdc3e84cef00df18e"
    )
  ),
  ide = "none",
  project_path = "."
)

Always specify a commit hash, not a branch name. This ensures reproducibility: branch names can change, but commits are immutable.

If the R package lives in a subfolder of the repository, append the subfolder to the URL:

git_pkgs = list(
  package_name = "BPCells",
  repo_url = "https://github.com/bnprks/BPCells/r",  # Note the /r suffix
  commit = "16faeade0a26b392637217b0caf5d7017c5bdf9b"
)

Note that this will install the package from source, so if it’s a package that requires specific system dependencies, you will need to specify them manually (Nix takes care of this for you for packages that are available through nixpkgs, but not for ad-hoc packages from other sources).

4.5.4 Installing local packages

For local .tar.gz archives, place them in the same directory as your default.nix and use local_r_pkgs:

rix(
  r_ver = "4.3.1",
  local_r_pkgs = c("mypackage_1.0.0.tar.gz"),
  ide = "none",
  project_path = "."
)

Just like with packages from Git, note that this will install the package from source, so if it’s a package that requires specific system dependencies, you will need to specify them manually (Nix takes care of this for you for packages that are available through nixpkgs, but not for ad-hoc packages from other sources).

4.5.5 Why NOT to use install.packages()

It’s crucial to understand: never call install.packages() from within a Nix environment. Here’s why:

  1. Declarative environments: If you install packages imperatively, your default.nix no longer matches your actual environment.
  2. Leaking packages: Packages installed via install.packages() go to your user library, not the Nix environment. They’ll be visible to all Nix shells, breaking isolation.
  3. Reproducibility: The whole point of Nix is that your environment is fully defined by the default.nix. Ad-hoc installations defeat this.

Instead, add packages to your rix() call and rebuild the environment.

4.6 System Tools and TexLive

4.6.1 Adding system packages

Need command-line tools like Quarto or Git? Add them via system_pkgs:

rix(
  r_ver = "latest-upstream",
  r_pkgs = c("quarto"),
  system_pkgs = c("quarto", "git"),
  ide = "none",
  project_path = "."
)

Note that here we install both the R {quarto} package and the quarto command-line tool—they’re different things!

To find available packages, search at search.nixos.org.

4.6.2 TexLive for literate programming

For PDF output from Quarto, R Markdown, or Sweave, you need TexLive packages:

rix(
  r_ver = "latest-upstream",
  r_pkgs = c("quarto"),
  system_pkgs = "quarto",
  tex_pkgs = c("amsmath", "framed", "fvextra"),
  ide = "none",
  project_path = "."
)

This installs the scheme-small TexLive distribution plus the specified packages.

4.6.3 Python and Julia integration

{rix} can also add Python or Julia to your environment:

rix(
  date = "2025-02-17",
  r_pkgs = "ggplot2",
  py_conf = list(
    py_version = "3.12",
    py_pkgs = c("polars", "great-tables")
  ),
  ide = "none",
  project_path = "."
)

This creates a polyglot environment with R, Python, and the specified packages for each.

4.6.4 Installing Python packages with uv (impure)

Not all Python packages (or versions of packages) are available through Nix. Unlike CRAN, PyPI doesn’t get automatically mirrored—individual packages must be packaged by volunteers. If a Python package you need isn’t in nixpkgs, you can use uv as an escape hatch.

This approach is also useful when collaborating with colleagues who use uv but haven’t adopted Nix yet. uv is 10–100x faster than pip and generates a lock file for improved reproducibility.

The idea is to install uv in your shell (but not Python or Python packages through Nix):

rix(
  date = "2025-02-17",
  r_pkgs = "ggplot2",
  system_pkgs = c("uv"),
  ide = "none",
  project_path = "."
)

Then use uv from within your shell. We recommend:

  1. Specify Python packages in a requirements.txt file with explicit versions (e.g., scanpy==1.11.4)
  2. Set up a shell hook to automatically configure the virtual environment

Here’s a complete example with a shell hook:

rix(
  date = "2025-02-17",
  r_pkgs = "ggplot2",
  system_pkgs = c("uv"),
  shell_hook = "
    if [ ! -f pyproject.toml ]; then
      uv init --python 3.13.5
    fi
    uv add --requirements requirements.txt
    alias python='uv run python'
  ",
  ide = "none",
  project_path = "."
)

After running nix-shell, uv initialises a Python project with the specified version and installs packages from requirements.txt. This happens each time you enter the shell, but uv caches everything so it’s nearly instant after the first run.

4.6.4.1 Troubleshooting wheel issues

When using wheels (pre-compiled Python packages), you may encounter errors like:

ImportError: libstdc++.so.6: cannot open shared object file

This happens because wheels expect certain libraries in certain locations. Add this to your shell hook to fix it:

shellHook = ''
  export LD_LIBRARY_PATH="${pkgs.lib.makeLibraryPath (with pkgs; [ 
    zlib gcc.cc glibc stdenv.cc.cc 
  ])}":$LD_LIBRARY_PATH
  # ... rest of your hook
'';

If this seems complicated: yes, it is. This is exactly the kind of problem Nix aims to solve. When possible, prefer Python packages included in nixpkgs.

4.7 Building and Using Environments

4.7.1 Building with nix-build

Once you have a default.nix, build the environment:

nix-build

This downloads/builds all required packages and creates a result symlink in your project directory. The result file prevents the environment from being garbage-collected.

4.7.2 Entering with nix-shell

To use the environment interactively:

nix-shell

You’ll drop into a shell where R and all your packages are available. Type R to start an R session. If you’re using RStudio (and specified ide = "rstudio" in the call to rix()) then type rstudio to launch RStudio. If instead you configured Positron, (or any other IDE), open Positron, and open the project folder that contains the default.nix (make sure you also have an .envrc there for direnv to load the environment directly).

You can even run scripts directly without entering the shell:

nix-shell default.nix --run "Rscript analysis.R"

This is quite useful on CI/CD platforms.

4.7.3 Pure shells for complete isolation

By default, nix-shell can still see programs installed on your system. This is quite important to understand: building the environment happens in an isolated, hermetic sandbox, but when inside a shell, isolation is more porous and it is possible to use other systems tools. For example, you don’t need to install git inside the Nix development environment: you can just keep using the git executable already available on your system.

But it is possible to run the environment with increased isolation:

nix-shell --pure

This hides everything not explicitly included in your environment.

4.7.4 Garbage collection

Nix never deletes old packages automatically. To clean up:

  1. Delete the result symlink that will appear in your project’s folder after calling nix-build
  2. Run nix-store --gc

This removes all packages that are no longer referenced by any environment.

4.8 Converting renv Projects

If you have existing projects using {renv}, the renv2nix() function can help you migrate:

renv2nix(
  renv_lock_path = "path/to/project/renv.lock",
  project_path = "path/to/new_nix_project"
)

4.8.2 Caveats

  • Package versions may not match exactly due to how Nix handles snapshotting
  • If the renv.lock lists an old R version but recent packages, use the override_r_ver argument to specify a more appropriate R version

4.9 Summary

Let’s step back and recap why we have gone through all this trouble.

Traditional tools like {renv} or Python’s venv only capture part of the reproducibility puzzle. They track package versions but not the language version itself, nor system-level dependencies like GDAL or Java. This means your project can still break on a different machine, or even on your own machine after a system update.

Nix solves this by managing everything: R, Python, all packages, and all system dependencies. When you define an environment with {rix}, you get a complete, self-contained specification that anyone can use to recreate the exact same environment, on any machine, at any point in the future.

4.9.1 Quick Reference: Starting a New Project

Here is the workflow you will follow for every new project:

  1. Create an empty folder for your project:

    mkdir my-project
    cd my-project
  2. Write a gen-env.R file with your environment definition:

    library(rix)
    
    rix(
      date = "2025-04-11",
      r_pkgs = c("dplyr", "ggplot2"),
      ide = "none",  # or "rstudio" if you prefer
      project_path = ".",
      overwrite = TRUE
    )
  3. Add a .envrc file (only needed for Positron or VS Code, skip if using RStudio):

    use nix
    mkdir $TMP
  4. Bootstrap {rix} and generate default.nix:

    nix-shell -p R rPackages.rix

    Then in R:

    source("gen-env.R")

    Exit R with q() and the shell with exit.

  5. Build the environment:

    nix-build
    # you could also run `direnv allow` to allow direnv to load the environment
    # automatically and build the environment on first use
  6. Start working: Open the folder in Positron (which will load the environment via direnv), or run nix-shell followed by rstudio if you set ide = "rstudio".

That’s it. Your project is now reproducible. Anyone with Nix installed can clone your repository, run nix-build, and get the exact same environment you have.