Using Private GitHub Repositories with Nix Flakes

7 min read

Private GitHub repos and Nix flakes have a failure mode that wastes exactly the right amount of time to be infuriating. A flake input like this:

inputs = {
  my-tool.url = "github:myorg/private-tool";
  my-tool.inputs.nixpkgs.follows = "nixpkgs";
};

produces this on nix flake update:

error: unable to download 'https://api.github.com/repos/myorg/private-tool/tarball/...': HTTP error 404

A 404. Not a 401. Not “authentication required.” Just “not found” — as if the repo doesn’t exist. GitHub returns 404 for unauthenticated requests to private repos, specifically to avoid confirming that the repo exists. It’s a deliberate security choice that sends people down exactly the wrong debugging path — double-checking URLs, triple-checking org names, wondering if someone deleted the repo.

The fix is to tell Nix how to authenticate.

How access-tokens work

Nix has a built-in access-tokens setting for exactly this:

access-tokens = github.com=ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxx

When Nix fetches from github.com, it includes this token in the request headers. Simple key-value — the host on the left, the token on the right. One line handles every private repo on that host.

This setting can live in:

  • ~/.config/nix/nix.conf — user-level
  • /etc/nix/nix.conf — system-level (for the Nix daemon)
  • A separate file pulled in via !include

That third option is the important one. The !include directive lets you keep the token in its own file, managed and rotated independently from nix.conf. You don’t want a long-lived secret sitting in a config file that might be world-readable, committed to a dotfiles repo, or copied around between machines.

!include /path/to/access-tokens.conf

The included file contains the access-tokens = ... line. Nix reads it at evaluation time. The token never touches nix.conf itself.

Creating the right token

Before wiring up configuration, you need a token with the right scope.

Fine-grained tokens (the newer option):

  1. GitHub → Settings → Developer settings → Personal access tokens → Fine-grained tokens
  2. Repository access: select the specific private repos your flake needs
  3. Permissions: Contents → Read-only

That’s the minimum. Nix fetches tarballs from the GitHub API — it needs to read repository contents, nothing else. No write access, no admin, no workflow permissions.

Classic tokens with repo scope also work but grant far more access than Nix needs. If you’re already using a classic token for other purposes, it’ll work. If you’re creating one specifically for Nix, fine-grained is the better choice.

One token, all repos. If you give it access to myorg/tool-a, myorg/tool-b, and myorg/tool-c, a single access-tokens line authenticates fetches for all of them:

inputs = {
  tool-a.url = "github:myorg/tool-a";
  tool-b.url = "github:myorg/tool-b";
  tool-c.url = "github:myorg/tool-c";
};

No per-input configuration needed.

Pattern 1: NixOS with sops-nix

On NixOS, the Nix daemon runs as root and reads system-level configuration. sops-nix handles decryption — encrypted secrets in your repo, decrypted into /run/secrets/ during system activation.

Declare the secret and tell Nix to include it:

sops.secrets.nix_builder_access_tokens = { };

nix.extraOptions = ''
  !include ${config.sops.secrets.nix_builder_access_tokens.path}
'';

The encrypted secrets file (secrets/myhost.yaml) contains:

nix_builder_access_tokens: ENC[AES256_GCM,data:...,type:str]

The plaintext value is just:

access-tokens = github.com=ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxx

On activation, sops-nix decrypts the YAML, writes the plaintext to /run/secrets/nix_builder_access_tokens, and Nix picks it up via the !include. The token is never in your nix.conf, never in your repo, and only exists decrypted in a tmpfs mount.

Setting up sops-nix

If you haven’t set up sops-nix yet, here’s the short version. .sops.yaml at your repo root maps secrets to age keys:

keys:
  - &myhost age1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
  - &personal age1yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy

creation_rules:
  - path_regex: secrets/myhost\.(yaml|json|env|ini)$
    key_groups:
      - age:
          - *personal
          - *myhost

Each host’s age key comes from its SSH host key:

ssh-keyscan myhost.example.com 2>/dev/null | ssh-to-age

Create or edit secrets with:

sops secrets/myhost.yaml

sops encrypts the file with every key listed in the matching creation rule. The host can decrypt with its own key, and you can decrypt with your personal key.

Pattern 2: macOS/Darwin with activation scripts

macOS doesn’t have NixOS’s activation system, but nix-darwin provides activation scripts that run during darwin-rebuild switch. The approach: decrypt the token with sops-nix, then copy it into the user’s Nix config directory.

system.activationScripts.postActivation.text = ''
  if [ -f /run/secrets/nix_builder_access_tokens ]; then
    USER_NIX_DIR="/Users/''${user.username}/.config/nix"
    mkdir -p "$USER_NIX_DIR"
    cp /run/secrets/nix_builder_access_tokens "$USER_NIX_DIR/access-tokens.conf"
    chmod 600 "$USER_NIX_DIR/access-tokens.conf"
    chown ''${user.username}:staff "$USER_NIX_DIR/access-tokens.conf"
    grep -q '!include' "$USER_NIX_DIR/nix.conf" 2>/dev/null || \
      echo '!include access-tokens.conf' >> "$USER_NIX_DIR/nix.conf"
    chown ''${user.username}:staff "$USER_NIX_DIR/nix.conf"
  fi
'';

This copies the decrypted token to ~/.config/nix/access-tokens.conf with mode 600 (owner-read-only), then ensures nix.conf has the !include line. Idempotent — safe to run repeatedly.

On macOS, Nix typically runs in single-user mode or the daemon reads from the user’s config. Either way, the token ends up where Nix can find it.

Remote builders

If you use remote builders, the builder machine needs the access tokens too. It’s the machine doing the fetching — your local Nix sends it a build job, and the builder pulls the flake inputs.

nix.buildMachines = [
  {
    hostName = "builder.example.com";
    systems = [ "x86_64-linux" "aarch64-linux" ];
    protocol = "ssh-ng";
    sshUser = "root";
    sshKey = "/etc/nix/builder_ed25519";
    maxJobs = 64;
    speedFactor = 2;
    supportedFeatures = [ "nixos-test" "benchmark" "big-parallel" "kvm" ];
  }
];

Same !include pattern on the builder host. If the builder doesn’t have the token, it hits the same 404 when it tries to fetch your private inputs. There’s nothing special about how remote builders authenticate — they just need the same access-tokens configuration as any other Nix installation that fetches from private repos.

The builder’s SSH key can also be managed via sops-nix on the machine that initiates the connection.

The bootstrap problem

There’s a chicken-and-egg issue with fresh hosts. A newly installed NixOS machine needs access tokens to build its own configuration (because the flake has private inputs), but the tokens are in sops-nix secrets that only get deployed after a successful build.

The solution: build on an existing host that already has tokens, deploy the result to the new host.

nix run nixpkgs#nixos-rebuild -- switch \
  --flake .#myhost \
  --target-host root@myhost.example.com \
  --build-host root@builder.example.com

The builder fetches the private inputs with its own tokens, builds the system closure, and ships it to the target. After activation, sops-nix decrypts the tokens on the new host, and from that point forward it can build its own config.

This is a one-time problem per host. I wrote a full post on the bootstrap dance if you want the details.

Common mistakes

Token in nix.conf directly. Tempting for a quick test, dangerous as a habit. /etc/nix/nix.conf is often world-readable. Use !include and a separate file with restrictive permissions.

Using netrc instead of access-tokens. Nix supports ~/.config/nix/netrc for HTTP authentication, and it works. But access-tokens is purpose-built for this — it’s simpler, specific to code forges, and doesn’t conflict with other tools that read netrc files.

Forgetting the Nix daemon. On multi-user Nix installs (the default on NixOS and recommended on macOS), the Nix daemon does the fetching. If you put the token in your user config but the daemon reads from /etc/nix/nix.conf, the daemon never sees it. On NixOS, nix.extraOptions writes to the system-level config. On macOS, make sure the daemon’s config includes the token, not just your user config.

Fine-grained token without Contents permission. The GitHub fine-grained token UI has a lot of permission knobs. If you skip Contents read or leave it at “No access,” Nix gets the same 404. It’s not a different error — you just don’t have permission to see the tarball.

Token expired or revoked. Fine-grained tokens have expiration dates. If your flake worked last week and doesn’t today, check the token. GitHub doesn’t send you a reminder email before it expires.

The minimal setup

If you just want it working and you’ll worry about secrets management later, the fastest path:

  1. Create a fine-grained token with Contents read on your private repos
  2. Add one line to ~/.config/nix/nix.conf:
access-tokens = github.com=ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxx
  1. Run nix flake update

That gets you unblocked. Then move the token into !include and sops-nix at your own pace. The sops-nix setup is the right long-term answer — encrypted at rest, automatically deployed, rotatable — but it’s not a prerequisite for fetching private flake inputs. One line in your Nix config is all that’s strictly required.