Skip to content

Repository files navigation

gems4nix

Bundle Ruby gems into a Nix environment using Bundler checksums from Gemfile.lock -- no bundix or gemset.nix needed.

Quick Start

Add gems4nix to your flake inputs, apply the overlay, and call gemfileEnv:

{
  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs?ref=24.11";
    gems4nix = {
      url = "github:omc/gems4nix";
      inputs.nixpkgs.follows = "nixpkgs";
    };
  };

  outputs = { nixpkgs, gems4nix, ... }:
    let
      pkgs = import nixpkgs {
        system = "aarch64-darwin"; # or your system
        overlays = [ gems4nix.overlays.default ];
      };
      gems = pkgs.gemfileEnv {
        name = "my-app-gems";
        gemfile = ./Gemfile;
        gemfileLock = ./Gemfile.lock;
      };
    in {
      # Put `gems` in buildInputs or a devShell's packages: its setup hook
      # exports GEM_HOME and GEM_PATH.
    };
}

What it does:

  • Parses Gemfile.lock (including checksums) in pure Nix
  • Resolves platform-specific gem variants for your system (prefers precompiled native gems over source compilation)
  • Builds each gem with nixpkgs' buildRubyGem and combines them into a buildEnv

Prerequisites

  • Bundler >= 2.5, with checksums turned on -- gems4nix reads the SHA256 of every gem from the CHECKSUMS section, and no Bundler release writes that section by default. Run bundle lock --add-checksums once (or set BUNDLE_LOCKFILE_CHECKSUMS=true) and commit the result. Bundler 2.5.22 and 2.7.2 both produce it on request; releases before 2.5 cannot.
  • Platform entries in your lockfile -- run bundle lock --add-platform to add precompiled native gem variants for your target systems.

Which platforms to add for which Nix system:

Nix system bundle lock --add-platform ...
aarch64-darwin arm64-darwin
x86_64-darwin x86_64-darwin
aarch64-linux aarch64-linux aarch64-linux-gnu aarch64-linux-musl
x86_64-linux x86_64-linux x86_64-linux-gnu x86_64-linux-musl

To cover all four systems at once:

bundle lock \
  --add-platform arm64-darwin x86_64-darwin \
  aarch64-linux aarch64-linux-gnu aarch64-linux-musl \
  x86_64-linux x86_64-linux-gnu x86_64-linux-musl

Common Errors and Solutions

"gems4nix: cannot find CHECKSUMS in Gemfile.lock - run 'bundle lock --add-checksums'" Your lockfile has no CHECKSUMS section. Bundler does not write one unless asked, so this is the common case for a lockfile that has never been through gems4nix:

bundle lock --add-checksums

If --add-checksums is not a recognised flag, your Bundler predates 2.5. Upgrade it with gem install bundler and run the command again.

"Could not find 'mini_portile2'" (or similar build-time dep) Your lockfile only has the ruby platform, so nokogiri (or similar) is being compiled from source and needs build-time dependencies that were filtered out. Add platform entries so the precompiled variant is used instead:

bundle lock --add-platform arm64-darwin  # (or your platform)

"curl: (22) The requested URL returned error: 401" The gem is on a private registry and the build has no credential for it. Declare one with the credentials argument. Note that netrc-file in nix.conf cannot fix this: it configures Nix's own downloader, not the curl a derivation runs. See Private Gem Registries.

"gems4nix: no credential available for <host>" You declared usernameVar/passwordVar for that host but the variable is empty inside the build. On multi-user Nix the value has to be on the daemon's environment, not your shell. See Mode 2: from the build environment.

"gems4nix: cannot read the netrc for <host> at <path>" You declared a netrcFile the build user cannot read. Absent and unreadable look identical from inside the build, so check both: every directory on the path must be traversable by the build user, and on Linux the path must be in extra-sandbox-paths. See Mode 1: from a file you control.

"gems4nix: '<gem>' has no checksum and no GIT/PATH source in the lockfile" A CHECKSUMS line carries no hash, which means the gem came from a GIT or PATH section, and no such section in the lockfile provides it. The usual cause is a hand-edited or truncated lockfile. Regenerate it with bundle lock. gems4nix refuses rather than dropping the gem, because a dropped gem shows up much later as a LoadError naming a layer that is not at fault.

"gems4nix: PATH source '<dir>' does not exist at <path>" A PATH section's remote: resolved to a directory that is not there. remote: is relative to root, which defaults to the directory holding the Gemfile. If the Gemfile is not co-located with its path gems, pass root explicitly. Note that Nix can only see a path inside the flake's source tree.

"gems4nix: '<gem>' is provided by more than one GEM section in the lockfile" Two GEM sections both list that gem, and nothing in the lockfile says which remote it should come from. Bundler locks a resolved gem under the single source that resolved it, so this is not a lockfile bundle lock writes; a hand-edited or merged one is the usual cause. Pick a remote in the Gemfile with a source block and re-run bundle lock:

source "https://rubygems.pkg.github.com/omc" do
  gem "depot"
end

"gems4nix: the lockfile was resolved with Ruby X, and this environment is built with Ruby Y" The RUBY VERSION section of your lockfile names a Ruby whose major.minor differs from the one gemfileEnv builds against. Gems install under lib/ruby/gems/<major>.<minor>.0 and native extensions compile against that ABI, so every gem in the environment would be built for a Ruby the lockfile does not describe. Pass a matching ruby to gemfileEnv, or re-run bundle lock under the Ruby you build against. The same message at warning level, which does not stop the build, means the two differ only below the ABI. See The Ruby the lockfile was resolved with.

"gems4nix: '<gem>' has a checksum but no GEM section provides it" A CHECKSUMS line carries a hash, which means the gem came from a GEM section, and no GEM section in the lockfile lists it. There is nowhere to fetch it from. A hand-edited or truncated lockfile is the usual cause; regenerate it with bundle lock.

"gems4nix: the value of $VAR, the credential for <host>, contains a newline" A netrc entry is a single line, so a credential value carrying a newline would write further lines of its own into the netrc and could claim another host. The usual cause is a secret read from a file with its trailing newline left on. Strip it where the variable is set. gems4nix refuses the same shape at evaluation time for anything it can see then: a newline in a credentials key, a usernameVar or passwordVar that is not a shell identifier, and a netrcFile path containing a character the shell acts on.

"gems4nix: PLUGIN SOURCE sections are not supported" Your lockfile has a PLUGIN SOURCE section, written by a Bundler plugin that supplies gems from somewhere gems4nix does not know how to fetch. There is no way to build those gems here. Remove the plugin from the Gemfile and re-run bundle lock, or vendor the gems it provides as a PATH source.

"gems4nix: GIT sources with a 'glob:' option are not supported (remote: <url>)" A GIT or PATH section carries glob:, which selects one gemspec out of several in a repository holding more than one gem. buildRubyGem builds the first *.gemspec it finds and cannot obey the glob, so honouring the section would silently build the wrong gem. Depend on the gem from a registry, or vendor the one subdirectory you want as its own PATH source so there is only one gemspec to find.

"gems4nix: unsupported key '<key>' in GIT section (remote: <url>)" A GIT or PATH section carries an option gems4nix does not recognise. Most such options change which files the gem is built from, so ignoring one means building something other than what the lockfile describes. The recognised GIT keys are remote, revision, ref, branch, tag, submodules and glob; a PATH section takes remote and glob. glob is recognised and then refused, with the dedicated message above, which is why it appears on both lists rather than falling to this one. If the key is one Bundler genuinely writes, that is a gap worth an issue — quote the section verbatim.

"Bundler::GitError: ... is not yet checked out. Run bundle install first." Your app boots through require "bundler/setup", one of its gems comes from a GIT section, and Bundler is not looking inside the gems4nix environment. gems4nix writes the checkout Bundler wants, under the environment's GEM_HOME; what usually goes wrong is that something else set GEM_HOME afterwards, or the environment was never on the build's buildInputs in the first place. Print Bundler.bundle_path and check it is the store path holding your gems. See Booting through require "bundler/setup".

"gems4nix: two gems4nix environments are on this shell, and GEM_HOME can only name one." Two gemfileEnv results are on one shell's inputs, and the second one's setup hook refused rather than take GEM_HOME off the first. GEM_PATH would have held both, so plain require would have kept working while Bundler lost sight of one environment's git gems, which is the failure the refusal exists to prevent. Build one gemfileEnv from both Gemfiles, or put the two environments in separate shells. The message names both paths. Under direnv you will see this message followed by Falling back to previous environment!, and your command will run in the previous environment rather than stopping.

"Gem::FilePermissionError" from gem install or bundle install GEM_HOME points into /nix/store, which is read-only, because that is where Bundler has to look for a git gem. Add the gem to the Gemfile and rebuild the environment. If a shell genuinely needs a writable gem directory for something else, set GEM_HOME again after the environment's setup hook has run, and accept that Bundler will no longer find any git gem in it.

"could not read Username for 'https://github.com'" while evaluating A private GIT remote is fetched by builtins.fetchGit, which shells out to your own git, and https with no credential helper cannot authenticate. A url."git@github.com:".insteadOf "https://github.com/" rewrite in your git config works. credentials does not apply here: it covers private gem registries, not git remotes.

"gems4nix: unsupported system '...'" The automatic platform detection does not recognize your stdenv.hostPlatform.system. Pass an explicit platforms list:

gemfileEnv {
  # ...
  platforms = [ "ruby" "arm64-darwin" "universal-darwin" ];
};

Build fails for a specific gem Some gems need extra build inputs or patches. Check whether nixpkgs.defaultGemConfig already has an override for that gem. If not, supply one via gemConfig:

gemfileEnv {
  # ...
  gemConfig = pkgs.defaultGemConfig // {
    my-gem = attrs: {
      buildInputs = [ pkgs.openssl ];
    };
  };
};

"gems4nix: Gemfile uses the gemspec directive but no gemspec was supplied" Your Gemfile calls gemspec (the default bundle gem layout). Group inference runs Bundler against a sandboxed Gemfile, so the .gemspec — and anything it require_relatives — must be handed to gemfileEnv explicitly:

gemfileEnv {
  # ...
  gemspec    = ./my-gem.gemspec;
  extraFiles = {
    "lib/my_gem/version.rb" = ./lib/my_gem/version.rb;
  };
};

Alternatively, pass an explicit gemGroups = { name = [ "default" ]; ... } mapping to skip Bundler group inference entirely.

How It Works

The pipeline has three stages:

  1. Parse (parse.nix) -- reads Gemfile.lock in pure Nix and produces a list of gem attribute sets with name, version, platform and source. A gem from a GEM section carries its remote and its SHA256 from the CHECKSUMS section; a gem from a GIT or PATH section carries the revision or the directory to build from instead.

  2. Resolve (resolve.nix) -- filters gems by requested groups and target platforms, expands transitive dependencies, and resolves each gem name to exactly one variant (preferring precompiled native over ruby-platform).

  3. Build (default.nix) -- applies gemConfig overrides (only to ruby-platform gems), calls buildRubyGem for each resolved gem, and combines them into a buildEnv.

Where a gem is fetched from

A GEM section names the remotes its gems come from, and gems4nix gives every gem in that section every one of them. Bundler puts more than one remote: line in a single section when a Gemfile declares more than one global source, and it looks the last-declared one up first — but the file is written the other way round. Source::Rubygems#add_remote unshifts each remote as the Gemfile declares it, and #to_lock reverses that back, so the lockfile lists them first-declared first. gems4nix reverses the file's order, which makes its list identical to Bundler's own remotes, and the fetch then tries them highest-priority first and stops at the first that serves the gem.

Reading the file top to bottom instead would try Bundler's lowest-priority source first, which is usually the public source line at the top of the Gemfile rather than the private registry added below it. Verified against Bundler 2.5.22, 2.6.6, 2.6.9 and 2.7.2 by scripts/bundler-remote-order.rb, which is also a nix flake check check, so a Bundler that changed the ordering shows up as a failing build.

Only the four-space lines under specs: are gems of a section. The six-space lines below each one name that gem's dependencies, which another section may well provide.

A gem that two GEM sections both claim is an evaluation error. Bundler locks a resolved gem under the single source that resolved it, so this is not a lockfile it writes. Faced with the same ambiguity while resolving, Bundler asks you to name the source in the Gemfile, and refuses outright under bundler_4_mode; older Bundler warns and takes the first source it saw. gems4nix refuses rather than copying that fallback, because here the "first source" is only the order the sections happen to appear in, and choosing by it would read as a decision when it is not.

The Ruby the lockfile was resolved with

Bundler writes a RUBY VERSION section when the Gemfile declares a ruby requirement, recording the Ruby that resolution actually ran on:

RUBY VERSION
   ruby 3.4.9p183

gems4nix compares it against the ruby it builds with, and how loudly depends on where they differ:

  • A different major.minor is an evaluation error. Gems install under lib/ruby/gems/<major>.<minor>.0 and native extensions compile against that ABI, so the whole environment would be built for a Ruby the lockfile does not describe. Pass a matching ruby, or relock.
  • A difference below that is a warning and the build proceeds. The requirement Bundler enforces at runtime lives in the Gemfile, not here — a Gemfile asking for ruby '~> 3.3' is satisfied by every 3.3.x, and this section only records which one resolution happened to use. The one thing that can still bite is a gem whose required_ruby_version falls between the two.
  • A lockfile with no RUBY VERSION section is not checked, because there is nothing to check it against.

The patchlevel is ignored. A ruby derivation's version never carries one, so there is nothing to compare it to.

Platform resolution

Many gems ship precompiled native variants alongside a pure-ruby fallback. The lockfile CHECKSUMS section lists all of them:

nokogiri (1.18.8) sha256=8c7464...          # pure ruby
nokogiri (1.18.8-arm64-darwin) sha256=483b...  # precompiled for Apple Silicon
nokogiri (1.18.8-x86_64-linux-gnu) sha256=4a7... # precompiled for x86 Linux

gems4nix narrows this down in three steps, matching what bundle install does:

  1. Filter by platform. Keep only variants whose platform is in the accepted set for this system. On aarch64-darwin that is ["ruby" "arm64-darwin" "universal-darwin"].

  2. Prefer native over ruby. If both arm64-darwin and ruby variants survive, pick the native one. This avoids source compilation.

  3. One gem per name. After resolution each gem name maps to exactly one derivation.

Git and path gem sources

A Gemfile entry with git:, github: or path: puts a GIT or PATH section at the top of the lockfile, and the gem gets a CHECKSUMS line with no hash:

GIT
  remote: https://github.com/omc/errgonomic.git
  revision: f06314af89209f855019219fd198513855be0fd5
  branch: main
  specs:
    errgonomic (0.5.1)
      concurrent-ruby (~> 1.0)

PATH
  remote: vendor/hello_gem
  specs:
    hello_gem (0.1.0)

Both build with no extra configuration. A git gem is fetched by builtins.fetchGit at the pinned revision; a path gem is built from root + "/" + remote, where root defaults to the directory holding the Gemfile. Either way the result is an ordinary gem on the GEM_PATH, so require finds it.

Because builtins.fetchGit runs while Nix evaluates, and its result is not something a binary cache can serve, gemSrcOverrides replaces the source of a named gem with one you fetch yourself:

gemfileEnv {
  name = "app-gems";
  gemfile = ./Gemfile;
  gemfileLock = ./Gemfile.lock;

  gemSrcOverrides.errgonomic = pkgs.fetchFromGitHub {
    owner = "omc";
    repo = "errgonomic";
    rev = "f06314af89209f855019219fd198513855be0fd5";
    hash = "sha256-...";
  };
}

The value can also be a function, which receives the gem's parsed source and returns the src to use. Naming a gem with no GIT or PATH source is an evaluation error, so a misspelled name fails loudly rather than falling back to the network fetch you were avoiding.

root must be a Nix path, not a string. A . or .. in a remote: is resolved by path arithmetic, and a string is never copied into the store. The default — the directory holding the Gemfile — is wrong in one common case: a Gemfile generated with writeText lives in /nix/store, and every path remote would then resolve against that. Pass root explicitly there:

gemfileEnv {
  name = "app-gems";
  gemfile = pkgs.writeText "Gemfile" gemfileText;
  gemfileLock = ./Gemfile.lock;
  root = ./.;
}

A PATH source that does not exist under root is an evaluation error naming root, not a silent skip.

Booting through require "bundler/setup"

Read this before putting a git gem in a Rails app.

A GEM or PATH gem needs nothing here. Bundler resolves a rubygems gem through Gem::Specification, which reads the GEM_PATH, and it reads a path gem's gemspec straight out of its source directory.

A GIT gem is different. Bundler::Source::Git reads it from bundler/gems/<repo>-<shortrev> under Bundler's install path and nowhere else, so an environment holding only the RubyGems layout raises:

bundler/source/git.rb:236:in `rescue in load_spec_files':
  https://github.com/omc/errgonomic.git (at main@f06314a) is not yet
  checked out. Run `bundle install` first. (Bundler::GitError)

gems4nix writes that directory as well, alongside the ordinary gem, so both require "errgonomic" and require "bundler/setup" work. The directory is named the way Bundler names it: after the repository, which is routinely not the gem's name, and twelve characters of the locked revision.

Two things have to be true at runtime, and the environment's setup hook handles the first:

  • GEM_HOME points at the environment. Bundler's install path is Gem.dir, so a GEM_HOME left pointing at ~/.local/share/gem sends Bundler looking for the checkout there. Putting the environment in buildInputs, or in a devShell's packages, is enough: the hook exports both GEM_HOME and GEM_PATH. Setting BUNDLE_PATH instead does not work: Bundler appends ruby/<version> to that one.
  • BUNDLE_GEMFILE names a Gemfile with its Gemfile.lock beside it, and with any PATH source's directory where the lockfile's remote: says. Bundler finds these on its own for an app run from its own directory. Setting BUNDLE_FROZEN=1 alongside is worth it: without it a lockfile Bundler disagrees with is rewritten at boot rather than reported.

Because GEM_HOME is a read-only store path, gem install and bundle install into the environment fail with Gem::FilePermissionError. That is the point. Declare the gem in the Gemfile and rebuild. A shell that needs a writable gem directory for something else has to set GEM_HOME again after the hook has run, and gives up Bundler's view of any git gem by doing so.

One Gemfile per shell. GEM_PATH accumulates across environments and GEM_HOME cannot, so two gems4nix environments in one shell would leave one of them with git gems that plain require finds and Bundler does not. The second environment's hook refuses instead, printing both paths and the consequence. Build one gemfileEnv from both Gemfiles, or keep the environments in separate shells.

What the refusal does depends on how you enter the environment, and it is weaker under direnv than anywhere else. Measured:

Entry path What happens
nix build, nix flake check, CI The build fails with the message as its last log lines.
nix develop No shell opens; the message is the failure's last log lines.
nix print-dev-env Exits non-zero with empty stdout and the message on stderr.
direnv / nix-direnv The message is printed, then direnv: nix-direnv: Evaluating current devShell failed. Falling back to previous environment! and your command runs anyway, in the last environment that loaded — or in no gems4nix environment at all, if none ever did.

The direnv row is nix-direnv's policy for any devShell that fails to evaluate, not something a setup hook can opt out of: the fallback happens precisely because the hook failed. So a direnv user gets a printed explanation and a stale environment rather than a stop. The stale one is the last environment that worked, which is usually the state you want anyway, but it does not match your flake.nix until you fix the conflict. Run nix develop to see the message on its own.

The hook tells another gems4nix environment from a GEM_HOME you set yourself by an extra variable it exports, GEMS4NIX_GEM_HOME; a GEM_HOME of your own is overridden without comment. That variable has to name a directory in the Nix store that is really there before the hook will believe it, so a stray or stale export cannot cost you a shell. Both it and GEM_HOME describe a build environment and nothing more: gems4nix never bakes either into an output, and a build recipe that captures one is recording the machine it ran on.

A single git repository supplying several gems works, and the gems share one checkout directory the way a real bundle install would leave them: each gem contributes its own gemspec and its own files to bundler/gems/<repo>-<shortrev>. Two such gems shipping the same file path is the one case that fails, and it fails during the environment build with pkgs.buildEnv error: two given paths contain a conflicting subpath followed by a hint about "two different versions of the same package". That hint is misleading here: the two paths are two different gems out of one repository, not two versions of one gem.

A lockfile gems4nix cannot honour is an evaluation error rather than a gem missing from the environment: a hashless CHECKSUMS line no source claims, a PLUGIN SOURCE section, a glob: option, and any unrecognised key on a GIT or PATH section all throw and name what they found. Each has its own entry under Common Errors and Solutions, with the message as thrown and what to do about it.

Configuration

gemfileEnv accepts these parameters:

Parameter Default Description
name (required) Name for the resulting derivation
gemfile (required) Path to Gemfile
gemfileLock (required) Path to Gemfile.lock
groups ["default" "development" "production" "test"] Which Bundler groups to include
platforms auto-detected from stdenv List of Bundler platform strings
gemGroups auto-detected via gem-groups.rb Attrset of { gemName = [ "group1" ... ]; } to override group detection
gemspec null Path to the *.gemspec when the Gemfile uses the gemspec directive
extraFiles {} { "relative/dest" = ./src; } — files the gemspec reads at load time
gemConfig nixpkgs.defaultGemConfig Per-gem build overrides
credentials {} Private registry credentials keyed by remote host; each entry is { netrcFile } or { usernameVar, passwordVar }
root directory holding the Gemfile Directory that PATH source remote: values resolve against
gemSrcOverrides {} { gemName = src-or-function; } — replaces the source of a git or path gem
ruby nixpkgs.ruby Ruby derivation the gems and GEM_PATH are both built against
debug false Trace each gem as it is built

This table is the whole argument surface. Anything else is an evaluation error naming the argument, so repinning to a version that predates a feature fails at the call site rather than succeeding and ignoring it.

Group filtering example

gemfileEnv {
  name = "prod-gems";
  gemfile = ./Gemfile;
  gemfileLock = ./Gemfile.lock;
  groups = [ "default" "production" ];
};

Group extraction uses a Ruby IFD (gem-groups.rb) by default. To avoid IFD, pass gemGroups explicitly.

Private Gem Registries

A gem hosted on a private registry needs a credential inside the Nix build sandbox. Declare one per remote host. gems4nix does not care where the secret comes from, because the two available answers fail on different machines, so pick the one that fits yours.

The key is a bare host, matched against the remote each gem is fetched from. Gems on remotes you did not name are fetched unauthenticated, exactly as before. If you name a host no gem uses, gems4nix warns. That is almost always a typo.

A gem whose GEM section carries several remotes is fetched from each in turn until one serves it, so every credentialed remote among them gets its own entry in that gem's netrc — modes may be mixed, one host from a file and another from environment variables. Credentialing only the first would turn the fallback into a bare 401 with none of the diagnostics below.

Because a netrc entry is one line, anything that could add a line is refused. The host key, the two variable names and the netrcFile path are known while Nix evaluates, so a newline in any of them, a variable name that is not a shell identifier, or a path containing a character the shell acts on is an evaluation error naming the host and the field. A variable's value is not known until the build runs, so it is checked there instead, before the entry is written. A netrcFile's contents are deliberately exempt: that file is a netrc, so being several lines and several hosts is the point of it.

Either way the secret stays out of the Nix store: gems4nix writes a netrc into the build directory, which is discarded with the build.

Mode 1: from a file you control

credentials."rubygems.pkg.github.com".netrcFile = "/run/secrets/gem-registry-netrc";

The file is an ordinary netrc:

machine rubygems.pkg.github.com login your-username password ghp_…

netrcFile must be a string, not a Nix path literal. A path literal would copy the file into the store; a string is read at build time and never copied. gems4nix rejects a path literal rather than letting it through.

Two requirements, both of which produce a named error rather than a 401 if unmet:

  • The build user must be able to read it. The build does not run as you. Under a multi-user daemon it runs as a build user sharing no group with you, so a secret under a 0750 home directory is unreachable no matter its own mode. Grant it to the build group instead of the world.
  • On Linux, the sandbox must expose it. extra-sandbox-paths = /run/secrets/gem-registry-netrc. Darwin defaults to sandbox = false, so ordinary Unix permissions are the only gate there.

This mode needs no daemon configuration at all.

Mode 2: from the build environment

credentials."rubygems.pkg.github.com" = {
  usernameVar = "GEM_REGISTRY_USER";
  passwordVar = "GEM_REGISTRY_TOKEN";
};

gems4nix names both variables in the fetch derivation's impureEnvVars. Nix reads those from the environment of the process that runs the build, which on multi-user Nix is nix-daemon, not your shell: export GEM_REGISTRY_TOKEN=… before nix build has no effect. The variables have to be on the daemon's job, and it needs a restart to pick them up. On single-user Nix the build runs as you, so exporting them in the invoking shell is enough.

This mode needs no readable file anywhere, which is its advantage. Its cost is that populating the daemon's environment is machine-level configuration nothing in your project can express.

Do not set the value directly in nix.envVars. That option is the obvious-looking route and it is the wrong one: NixOS and nix-darwin render the value into the generated unit or plist, which lands in the Nix store at mode 0444. The token becomes readable by every local user and every process, permanently. Feed the daemon's environment from a file instead — see below.

Using a secret manager

Both modes work with sops-nix, which decrypts secrets at activation into /run/secrets rather than into the store.

For mode 1, decrypt the netrc and make it readable by the build group. Nix builds run as _nixbld* in group nixbld, so 0440 with that group keeps the file off world-readable paths:

sops.secrets."gem-registry-netrc" = {
  sopsFile = ./secrets/gem-registry-netrc;
  format = "binary";
  group = "nixbld";
  mode = "0440";
};

Then point gems4nix at config.sops.secrets."gem-registry-netrc".path, and on Linux add that path to nix.settings.extra-sandbox-paths.

For mode 2 on NixOS, render an environment file and hand it to the daemon unit, so the value reaches the daemon's environment without ever being written to the store:

sops.templates."nix-gem-registry.env".content = ''
  GEM_REGISTRY_USER=${config.sops.placeholder."gem-registry-user"}
  GEM_REGISTRY_TOKEN=${config.sops.placeholder."gem-registry-token"}
'';

systemd.services.nix-daemon.serviceConfig.EnvironmentFile =
  config.sops.templates."nix-gem-registry.env".path;

There is no launchd equivalent of EnvironmentFile, so mode 2 on darwin means wrapping the daemon's ProgramArguments to source the file before execing nix-daemon. Mode 1 is the simpler fit on darwin.

Why not NIX_CURL_FLAGS

The workaround people usually arrive at is a netrc on disk plus NIX_CURL_FLAGS=--netrc-file /etc/nix/netrc on the daemon. It works. Its cost is easy to miss: NIX_CURL_FLAGS has to be set on the daemon and the file has to be readable by the build user, and because the flag is set machine-wide rather than per fetch, the file it names is conventionally /etc/nix/netrc at mode 0644. Every local user can then read the registry token.

NIX_CURL_FLAGS + netrc netrcFile usernameVar / passwordVar
needs the daemon environment yes no yes
needs a readable secret on disk yes yes no
can be scoped to the build group in principle yes n/a
declared in the derivation no yes yes
secret in the store no no no
scoped to the hosts that need it no yes yes

All three keep the secret out of the store. What credentials adds is that the requirement is stated where the fetch happens, scoped to the hosts that need it, and reported by name when it is missing.

netrc-file in nix.conf does not apply

netrc-file configures Nix's own downloader: substituters, flake inputs, builtins.fetchurl. A derivation that runs its own curl never consults it. This is why a private github: flake input resolves on a machine where a gem fetch still returns 401. Being in trusted-users does not help either; that governs which settings a client may send to the daemon.

Contributing

See CONTRIBUTING.md for how to run tests, add fixtures, and contribute code. See ARCHITECTURE.md for a one-page project map.

Known Limitations

  • GEM_HOME is set, not appended. The environment's setup hook exports GEM_HOME unconditionally, because a GEM_HOME that silently wins is how a git gem goes missing with nothing to say why. Two consequences: gem install from a shell holding a gems4nix environment now fails with Gem::FilePermissionError where it previously installed into the user's own gem directory, and two gems4nix environments in one shell are refused rather than one quietly losing. Both are described under Booting through require "bundler/setup".

  • That refusal does not stop a direnv user. It stops nix build, nix develop, nix print-dev-env and CI. Under nix-direnv the message is printed and then the previous environment is served and the command runs, because nix-direnv falls back on any devShell evaluation failure. Nothing a setup hook can do survives that fallback, since the fallback is triggered by the hook failing; a warning that let the environment build would be worse, because it would hand you the conflicting environment instead of the last working one. Measured with direnv 2.37.1 and nix-direnv 3.1.2.

  • A git gem with a native extension is not usable under bundler/setup. RubyGems installs the compiled extension to extensions/<arch>/<api>/<gem>-<version>, and Bundler asks a git-sourced spec for extensions/<arch>/<api>/<repo>-<shortrev> instead. The two names never meet, so the .so is missing from the load path. Measured on both sides, not yet exercised end to end: no example has a git gem with a C extension. A pure-Ruby git gem is unaffected.

  • A git gem with a native extension cannot see its build-time siblings. buildRubyGem takes those through gemPath, and gems4nix does not set it. This is not specific to git gems, but a git gem is where it bites first.

  • builtins.fetchGit runs at evaluation time. Any command that evaluates an output holding a git gem needs the network then, and needs credentials then for a private repository. The result is not a fixed-output derivation, so no binary cache can serve it. gemSrcOverrides is the way out.

  • A private git remote depends on the invoking user's git configuration, which is a different axis from credentials above. credentials covers private registries fetched over fetchurl; a git remote is fetched by the user's own git. A url.<ssh>.insteadOf rewrite works. A bare https remote fails with could not read Username. Nix's access-tokens and netrc-file settings configure Nix's downloader, not this git, so neither applies.

  • branch:, tag: and ref: are recorded and never used. The fetch goes by revision alone, which is what determines the store path, so adding any of them would return the identical result.

  • A path gem must live inside the flake's source tree. Its remote: resolves against root, and Nix can only copy a source it can see.

  • Only GitHub git remotes have been tried. A locked revision is often not a branch tip, and a server with uploadpack.allowAnySHA1InWant off refuses to send one on its own; gems4nix asks for every ref to work around that.

  • Bundler >= 2.5 is required, and its CHECKSUMS section has to be enabled explicitly with bundle lock --add-checksums.

  • Group extraction uses Ruby IFD by default. This is an impurity at Nix evaluation time: evaluating a gemfileEnv runs Bundler in a derivation and reads its output back. Pass gemGroups to avoid it.

    Reading the groups out of the lockfile instead is not possible. Bundler does not write them there: Bundler::Dependency#to_lock produces the same DEPENDENCIES line whatever groups a dependency has, and Bundler::LockfileParser reads every dependency back as [:default]. Groups live in the Gemfile, where arbitrary Ruby can produce them, so Bundler is what has to evaluate them.

About

No description, website, or topics provided.

Resources

Contributing

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages