skip to content
Greenfield logo Greenfield

Private Nix Flake with Public Git Subtree

· 6 min read

Recently I moved my personal NixOS configuration to a private repo, and split out part of the config into a public repo. It’s not rare to see private NixOS config with a public counterpart. Such setup often results in extra friction and requires additional efforts when maintaining. There are some tricks to make it easier to work with.

TL;DR

  • What we want:
    • A way to create private Nix flake, while exposing general and reusable stuff to the public.
    • Good developer experience. We want to work on the flake without extra steps compared to a full private flake. We don’t want to update the lock file whenever the public part is updated.
    • The public part can be used on its own, without having access to the private one.
  • How to do it:
    • Create a public flake for general and reusable config, and a private flake for sensitive config. The public flake exports modules and packages, consumed by the private flake.
    • Put both private flake and public flake in a private repo. Let the private flake import and evaluate the public flake manually, without making the public flake as flake input.
    • Make public and private flakes use the exact same flake inputs. Symlink the public flake.lock to the private flake, leaving only one lock file to maintain. The caveat is that the private flake cannot have extra flake inputs that the public flake doesn’t have.
    • Use git subtree to sync the public flake with a separate public repo.
  • Example https://github.com/yelite/private-flake-example.

Background

I used to put my config in a public repo. When I just started to use NixOS, public NixOS config repos from others are my favorite learning resource. They contain many tricks that cannot be easily found in the official doc. It’s so cool to see my config being available to help others get into the world of Nix. Besides the educational purpose, it’s convenient to have the config repo public so that it’s easy to bring my dev environment to some random ephemeral VMs, where I don’t want to install my ssh keys.

I made my config private when I was setting up my home lab server. I found that the NixOS config of my home lab server contains lots of sensitive information, which would reveal my home network topology, exposed ports and even public IP address. I certainly don’t want to make these sensitive bits accessible to everyone on the Internet.

Noted that sensitive information is different than secrets. I am okay to have sensitive information, like IP addresses and exposed network ports, in my Nix store, because if the attacker has access to my Nix store, it’s likely they also have access to these information through other channels (netstat, etc..). I don’t want to introduce things like nix-age or sops-nix to my config, which are designed for safe transportation of secrets in nix repo and they add unnecessary complexity for my use case.

It’s clear that I need a setup that puts all general config in a public flake and all sensitive config in a private flake, then somehow connect two flakes together. Typically this means the public flake needs to export packages and NixOS modules, and the private flake needs to add the public flake as its input. The drawback is, for most changes, we need to first create commit in the public flake, then run nix flake update public_flake_name in the private flake to update the lock file, before the private flake can see the latest change. This results more friction in the dev flow, slowing down the iteration speed.

Import and evaluate the public flake directly

The slowness of the setup described above is caused by the public flake being pinned by the lock file of private flake. We can circumvent this by manually importing and evaluating the private flake, without adding it as a flake input.

First we need to put both flakes in the same repo, the folder structure should be like this

$ tree
.
├── flake.lock -> public/flake.lock
├── flake.nix
└── public
├── flake.lock
└── flake.nix

The public flake.lock is symlinked to the private flake. This ensures both flakes are based on the same flake inputs, and things will work identically in both flakes.

The public/flake.nix should contain all the reusable code that can be made public. It should export, for example, nixosModules, homeModules or packages, for the private flake to consume.

The private flake imports the public flake, and evaluates it manually, as shown in the highlighted area below

flake.nix
{
outputs = inputs @ {nixpkgs, ...}: let
# evaluate the public flake directly, without having it in flake inputs.
publicOutputs = (import ./public/flake.nix).outputs (inputs // {self = publicFlake;});
publicFlake =
publicOutputs
// {
inherit inputs;
outputs = publicOutputs;
outPath = ./public;
_type = "flake";
};
in
# we need to ensure that the two flakes have same inputs, because they share the same lock file
assert (import ./public/flake.nix).inputs == (import ./flake.nix).inputs; {
nixosConfigurations =
# inherits all nixos config from the public flake
publicFlake.nixosConfigurations
// {
example-private = nixpkgs.lib.nixosSystem {
modules = [
# use the nixos module exported by the public flake
publicFlake.nixosModules.default
# combined with sensitive config
{
services.openssh.ports = [12345];
nixpkgs.hostPlatform = "x86_64-linux";
}
];
};
};
};
}

Publish the public repo through git subtree

Now that we have a private repo containing both private flake and public flake, we need a way to make the public flake actually public. Git subtree can be used to map a subdirectory in the working tree to a different git repo. We can use subtree to push the public flake to a public repo.

Terminal window
# Add a remote `public` pointing to the public repo
git remote add public [email protected]:yelite/private-flake-example-public.git
# Push the public subdirectory to the public repo
git subtree push --prefix=public public main --squash --rejoin
# `--squash --rejoin` is only needed for the first push, which creates
# a joining point of the history of public repo and private repo, so that
# `git subtree pull` knows how to merge the new commits from the subtree repo.

The public repo should contain files from the public/ directory, and will have complete history with commits that touch files under public/.

.
├── flake.lock
└── flake.nix

You can create commit in the public repo directly. New changes in the public repo can be pulled into the private repo by

Terminal window
git subtree pull --prefix=public public main --squash

Compared to git submodule, git subtree makes the dev workflow closer to a single-repo setup. If you want to make change involving both private and public flakes, you need to create two commits if using submodule, but only one if using subtree. Although pushing the public subtree requires manual work, it’s easy to automate this in CI if an up-to-date public repo is needed.