nix-github-token¶
Modules
A tiny NixOS module that hands Nix an authenticated GitHub personal access
token (PAT) so flake and fetchFromGitHub fetches stop hitting github.com's
rate limit — without ever writing the token into the Nix store.
The problem¶
Unauthenticated github.com access is throttled to 60 requests/hour per IP.
A machine that resolves many flake inputs or fetchFromGitHub sources burns
through that almost instantly, and then evaluations and builds stall on
API rate limit exceeded errors. An authenticated PAT raises the limit to
5000/hour. This bites hardest on agent/CI/build hosts that fetch a lot.
The fix Nix documents is the access-tokens setting:
But a naive way to set that has two failure modes this module avoids.
The two traps¶
1. Don't put the token in nix.conf.
/etc/nix/nix.conf is generated into the Nix store and is world-readable.
access-tokens is a secret. Setting it via nix.settings.access-tokens
leaks the PAT to every user on the box and into the store. Instead, this
module writes the access-tokens line into a file under /run (tmpfs,
0440) at activation time and never lets the secret touch the store.
2. Use !include, not include.
The token file under /run does not exist yet on a fresh boot — the
activation script hasn't run, and your secret manager may not have decrypted
the token. A plain include of a missing file makes every nix invocation
fail. The bang form, !include, is the optional include: Nix silently
ignores it when the file is absent, so you degrade gracefully to
unauthenticated (rate-limited) fetches instead of a hard error.
Usage¶
Import the module and point tokenFile at a file that will contain the raw
PAT at runtime. The module is secret-manager agnostic — anything that produces
a readable file works: agenix, sops-nix, a systemd credential, or a manually
placed 0400 file.
{
imports = [ ./modules/nix-github-token ];
services.nix-github-token = {
enable = true;
tokenFile = "/run/secrets/github-pat"; # produced by your secret system
# activationDeps = [ "agenix" ]; # order after the secret is placed
};
}
With agenix¶
age.secrets.github-pat = {
rekeyFile = ./secrets/github-pat.age; # or `file =` for plain agenix
mode = "0400";
};
services.nix-github-token = {
enable = true;
tokenFile = config.age.secrets.github-pat.path;
activationDeps = [ "agenix" ]; # run after agenix decrypts
};
With sops-nix¶
sops.secrets.github-pat = { };
services.nix-github-token = {
enable = true;
tokenFile = config.sops.secrets.github-pat.path;
activationDeps = [ "setupSecrets" ];
};
Options¶
| Option | Default | Purpose |
|---|---|---|
enable |
false |
Turn the module on. |
tokenFile |
(required) | Path to the file holding the raw PAT at runtime. |
host |
"github.com" |
Auth host. Set to your GitHub Enterprise host to authenticate there. |
runtimeFile |
"/run/nix-github-access-tokens" |
tmpfs path the access-tokens line is written to and !included from. |
activationDeps |
[ ] |
Activation steps to order after (whatever decrypts/places the token). |
Caveats¶
- Order the activation script after your secret. If
activationDepsis empty and the token isn't in place yet at activation time, the/runfile simply isn't written that cycle — you fall back to unauthenticated fetches until the next activation. SetactivationDepsto close that gap. - Rotate the PAT before it expires. GitHub PATs expire; a dead token gives
you
Bad credentialsand, effectively, unauthenticated rate limits again. Encode the expiry in your secret's filename if that helps you remember. - Scope the token minimally. For public-repo fetches a fine-grained token with read-only public access is enough; it does not need repo write scopes.
- The token file under
/runis0440. Only grant it to trusted local users; anyone who can read it can read the PAT.
Source¶
modules/nix-github-token/default.nix
# nix-github-token
#
# Feed Nix an authenticated GitHub personal access token (PAT) so that flake
# input resolution and `fetchFromGitHub` sources escape github.com's
# 60-request/hour unauthenticated, per-IP rate limit (authenticated: 5000/hr).
#
# Two deliberate design choices worth keeping:
#
# 1. The token is materialised into a file under /run (tmpfs), never baked
# into the store-resident, world-readable /etc/nix/nix.conf. `access-tokens`
# is a secret; it must not land in the Nix store.
#
# 2. Nix pulls the file in with `!include` (note the leading bang), which is
# the *optional* include form: Nix does NOT error if the file is missing.
# That matters because the file is absent on a fresh boot before the
# activation script has run, or before your secret-management system has
# decrypted the token. A plain `include` would make every nix invocation
# fail in that window.
#
# This module is secret-manager agnostic: point `tokenFile` at any file that
# ends up containing the raw PAT at runtime (agenix, sops-nix,
# systemd credentials, a manually-placed 0400 file, ...). Whatever produces
# that file should run before the `nix-github-access-tokens` activation script;
# set `activationDeps` accordingly (e.g. [ "agenix" ] or [ "setupSecrets" ]).
{
lib,
config,
...
}:
let
cfg = config.services.nix-github-token;
in
{
options.services.nix-github-token = {
enable = lib.mkEnableOption "authenticated github.com access for Nix fetches via a PAT";
tokenFile = lib.mkOption {
type = lib.types.str;
example = "/run/secrets/github-pat";
description = ''
Path to a file that will contain the raw GitHub personal access token
at runtime. Managed by your secret system of choice (agenix, sops-nix,
a systemd credential, etc). The file only needs to be readable by root
at activation time. If the file is absent the module quietly does
nothing, so builds fall back to unauthenticated (rate-limited) access.
'';
};
host = lib.mkOption {
type = lib.types.str;
default = "github.com";
example = "github.example.com";
description = ''
Host the token authenticates against. Use your GitHub Enterprise host
here if you fetch from a self-hosted instance.
'';
};
runtimeFile = lib.mkOption {
type = lib.types.str;
default = "/run/nix-github-access-tokens";
description = ''
tmpfs path the `access-tokens` line is written to and `!include`d from.
Kept out of the Nix store on purpose — it holds a secret.
'';
};
activationDeps = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
example = [ "agenix" ];
description = ''
Activation-script dependencies to order this script *after*. Set this to
whatever activation step decrypts/places `tokenFile` (e.g. "agenix" for
agenix, "setupSecrets" for sops-nix) so the token exists when we read it.
'';
};
};
config = lib.mkIf cfg.enable {
system.activationScripts.nix-github-access-tokens = {
text = ''
if [ -r ${lib.escapeShellArg cfg.tokenFile} ]; then
umask 077
printf 'access-tokens = ${cfg.host}=%s\n' \
"$(cat ${lib.escapeShellArg cfg.tokenFile})" > ${lib.escapeShellArg cfg.runtimeFile}
chmod 0440 ${lib.escapeShellArg cfg.runtimeFile}
fi
'';
deps = cfg.activationDeps;
};
# `!include` (bang) = optional include: no error when the file is missing,
# which it is on fresh boot / before the secret is decrypted.
nix.extraOptions = ''
!include ${cfg.runtimeFile}
'';
};
}