In the blink of an eye, April has passed, yet I still haven’t gotten around to writing. It’s not simply a matter of lacking time, I’ve just been so immersed in Flakes that I haven’t been able to pull myself away. With quite a few readers clamoring for an update, I decided to write and release a portion first. Since the beginning of the year, from using Flakes to uniformly manage various services and environments on macOS on my MacBook to the present, nearly all my Linux workstations and servers have been taken over and configured by Flakes. One could say it has become unstoppable. Paired with the powerful drive of Claude Code, I have found the ultimate best practice for Vibe DevOps.
Flakes
As early as 2024, I introduced Flakes in the article Play with Nix Flakes. However, that introduction was limited to simple configuration and service deployment, without touching upon the philosophy of unified management, thus, it could only be considered the tip of the iceberg. This year’s story began with a discussion I had with Gemini Pro at the start of the year. As a long time Linux user, I was still uncomfortable with the MacBook I had been using for nearly a year and found many aspects of macOS’s underlying design confusing. So I used Gemini Pro to look up and analyze materials. As my understanding of macOS’s underlying file system and application layer design deepened, an idea struck me: could I further leverage the power of AI to free myself from the overwhelming configuration and management of macOS? That is where the journey began.
This article leans more toward an introductory perspective. Regardless of whether I have covered certain concepts before, I will try to explain them as thoroughly as possible. However, to maintain my personal style, topics already detailed in the official documentation will only be referenced without redundant explanation.
Getting back to the point, what exactly are Flakes? This question is already explained in great detail and with rigor in NixOS Wiki: Flakes. In short, Nix provides us with a standardized expression syntax through Flakes, making our builds reproducible to the greatest extent possible. This means we can use Flakes to build stable, reproducible services, environments, or even an entire infrastructure. Many people therefore interpret Flakes as a kind of configuration file, let’s use this perspective as a starting point to examine how Flakes differ from traditional configuration files:
- Hermetic Evaluation
In my view, this is the most revolutionary feature of Flakes, and the aspect that most distinguishes it from traditional configuration files. In conventional builds, we are inevitably influenced by various implicit contexts propagated from higher or lower layers. Even with identical configuration files, two machines can produce completely different results, this is precisely why the It works on my machine meme persists. Flakes, however, severs these implicit connections at the architectural level. Under the mechanism of Hermetic Evaluation, Flakes enforces that all dependencies must be explicitly declared in inputs. This reduces the entire evaluation process to a pure mathematical function: given the same Flakes code, it will inevitably produce identical derivations, thereby ensuring the determinism of build results.
- Engineered Dependency Management
If inputs isolates boundaries and cuts off implicit spatial dependencies, then flake.lock eliminates temporal version drift. Readers familiar with Rust will recognize Cargo.lock, similar examples include Node.js’s package-lock.json and Golang’s go.sum. In modern engineering, using lock files to record exact dependency versions and hashes has become an industry consensus. flake.lock freezes the entire state of the inputs dependency tree at a specific moment by recording precise Git commit hashes. This guarantees the uniqueness of build results, whether today, five years from now, or even ten years from now, executing the same build will retrieve code that hasn’t changed by a single bit. With this, Flakes completes the final piece of the deterministic build puzzle.
- Standardized Contracts
Beyond inputs and flake.lock, Flakes introduces a set of interfaces with strict schemas. By exposing structurally consistent outputs, it transforms infrastructure into programmable, standardized modules. In traditional DevOps ecosystems, underlying implementations and automation tools operate independently, Dockerfiles define container environments, Ansible handles configuration distribution, and assorted pipelines stitch workflows together. A charitable interpretation calls this modularization, but in reality, each component has its own black-box logic and non-standardized input/output conventions. Achieving code-level cross-platform reuse and modular invocation of infrastructure is nearly impossible. With Flakes, however, whether it is darwinConfigurations for managing macOS environments, devShells for unified development environments, or standalone binary packaging, everything operates under a single standardized API contract. This effectively removes barriers between systems and deployment processes, enabling full lifecycle management of devices and services in a pure, modular manner.
- Atomic Module Composition
Building upon isolation, freezing, and standardization, Flakes fully unleashes Nix’s atomic advantages. In traditional system management and configuration toolchains, cross device configuration reuse often depends on complex conditional relationships or fragile script reuse. Under Flakes’ declarative architecture, complex infrastructure and system environments are decomposed into pure logical modules and statically compiled artifacts. Leveraging Nix’s atomicity, system and service configurations are no longer lengthy, unpredictable shell scripts, but instead become declarative compositions that can be invoked on demand and freely assembled. Within Flakes’ highly abstract modular paradigm, there is no longer a need to repeatedly reason through deployment processes across heterogeneous environments. Instead, components can be flexibly assembled and reused like building blocks according to one’s own service abstractions and design logic. For example, a complex mesh network like Gravity and all its dependencies can be packaged into a single module and introduced on demand to target servers, eliminating the need for fragile batch deployment scripts. Similarly, frequently used development environments can be composed into a dev module and reused across multiple workstations, ensuring consistency across devices.
In summary, Flakes is not merely a collection of configuration files, but a rigorous realization of Infrastructure as Code.
That concludes the discussion of design philosophy for now. For more details, you can refer to the official documentation. Next, we can start with deployment and briefly organize some introductory concepts, while more advanced use cases can be explored in future articles.
Deployment
First is the installation and deployment of Nix. You can simply follow the example from Nix: Multi-user installation, using Debian as an example:
sh <(curl --proto '=https' --tlsv1.2 -L https://nixos.org/nix/install)
On macOS, simply remove --daemon:
sh <(curl https://mirrors.tuna.tsinghua.edu.cn/nix/latest/install) --daemon --no-channel-add
Configuration
Since it is nominally still an experimental feature, Nix does not enable Flakes by default. You need to configure it manually to enable it. Still using Debian as an example:
mkdir -p ~/.config/nix
echo "experimental-features = nix-command flakes" > ~/.config/nix/nix.conf
The configuration changes will take effect in new sessions.
Update
After completing the deployment and configuration, I usually make a habit of updating the nix and nss-cacert installed by the installation script:
nix profile add nixpkgs#nixVersions.latest --priority 4
nix profile add nixpkgs#cacert --priority 4
nix profile remove nix
nix profile remove nss-cacert
nix-collect-garbage -d
nix profile list
This step is not mandatory, it is just for the convenience of future updates. You can skip it if not needed.
Repository
A Flakes repository intended for unified management naturally demands a solid architectural design, paving the way for us to maintain our infrastructure elegantly down the road. There is, of course, no silver bullet here, the design should simply be tailored to your actual needs. Based on the fleet of devices I manage, they can be broadly divided into two major categories: everyday workstations and servers. Adhering to the philosophy of simplicity and elegance, the architecture of my Flakes repository is laid out as follows:
.
├── CLAUDE.md
├── flake.lock
├── flake.nix
├── hosts
│ ├── devices
│ └── servers
├── modules
│ ├── common.nix
│ ├── devices
│ └── servers
├── pkgs
├── README.md
└── secrets
First, there are the essential flake.nix and flake.lock. What exactly these two do has already been explained earlier, so I won’t go into detail here.
Next is the hosts section. Starting from here, this is an abstraction I designed myself, neither Nix nor Flakes mandates the inclusion of this directory in the repository. According to my design, the hosts directory is used to store the corresponding configurations for different devices, you can think of it as a separate small room prepared for each machine. Under hosts, we divide it into two subdirectories: devices and servers. This is to distinguish between the two completely different device types, everyday workstations and servers, so that we can clearly see the device management status of our Flakes at a glance when maintaining it.
Below is the modules section, which is also an abstraction of my own design. Neither Nix nor Flakes requires this directory to be present in the repository. In my design, modules is used to store reusable modular components. Complex system logic, service configurations, or abstractions of development environments are encapsulated here, and any device in hosts can freely reference them. For example, the Gravity network project mentioned earlier involves multiple low level network components, such as IPSec, StrongSwan, Bird, and ranet, and complex configuration strategies. Repeatedly writing and maintaining these configurations on different node devices easily leads to configuration fragmentation and brings enormous mental overhead. However, by fully encapsulating it into a callable module, the complex underlying implementation is completely hidden. At this point, all devices under hosts automatically gain the ability to access the complete Gravity network, you only need to simply import it and pass in node parameters to complete the setup. Furthermore, the dev module, which is the most frequently changed and most useful part of my personal Flakes, aggregates the various programming languages and their toolchains I frequently use, VSCode along with its configurations and extensions, as well as Claude Code and its various MCPs and Skills. Once all these development related odds and ends are centrally managed by the dev module, it becomes incredibly elegant. I no longer need to modify and configure each machine, I simply import the dev module in hosts to instantly replicate a completely identical development experience across all devices.
The pkgs directory is also an abstraction of my own design. It was designed as a flat directory from the very beginning. Although upstream Nixpkgs already provides an extremely vast software ecosystem, we still need some applications that we want to package ourselves or that require special patches, and the pkgs directory is specifically used to collect compilation products that are not available upstream or are purely custom for personal use. Leveraging the standardized interface of Flakes’ outputs, we not only allow the devices in hosts to freely invoke these custom packages, but we can also reference them in any module and make them a part of our infrastructure. The reason I emphasize that it is designed as a flat directory is that, within pkgs, I don’t distinguish the specific package’s function category or domain, all custom derivation recipes here are equal first class citizens. By discarding these hierarchical relationships, the maintenance cost of adding and retrieving new packages is reduced to the absolute minimum. In addition, coupled with Flakes’ native support for cross platform, these flatly stored build recipes can be evaluated and compiled independently on Linux servers or nix-darwin based macOS workstations.
CLAUDE.md is the masterstroke here. Under the reliability provided by Flakes, I introduced Claude Code, a modern Vibe Coding tool to help me write and maintain large Nix expressions, and made good use of CLAUDE.md to establish global context conventions with the LLM in advance. With Vibe Coding, I can truly achieve the goal of doing DevOps work just by talking, freeing myself from tedious configurations and syntactic details, and allowing me to focus my energy on the top level design of the system architecture and the orchestration of module logic. In this workflow, the deterministic nature provided by Flakes serves as a perfect safety net. No matter how the LLM introduces dependencies or rewrites logic, everything remains perfectly clear during code review, and declarative code ensures the absolute consistency of the final build and the codebase graph. This completely eliminates the unknown side effects and state residues that can occur when running traditional scripts, which OpenClaw and its ilk simply cannot compare to. Interestingly, under the support of this robust architecture, I even let Claude Code start managing itself through Flakes. For example, I suggested that I wanted Claude Code to proactively call me when it needs my intervention. It evaluated the current Flakes architecture on its own and precisely introduced the peon-ping project into the dev module to establish a notification mechanism. In another equally interesting example, I found it a bit cumbersome to visit NixOS Search - Packages every time, so I expressed the hope that Claude Code could find the necessary software packages on its own. As a result, it precisely introduced MCP-NixOS into the dev module, and from then on, I no longer need to search for software packages manually.
Finally, there is the secrets directory, which is specifically used to store content that needs to be encrypted. We can leave it for the upcoming SOPS section to discuss individually.
Other Designs
Of course, the aforementioned architecture is entirely tailored to my own use cases and has evolved and taken shape along with my continuous use. Whether you are relatively new to Flakes or have never touched Nix but are eager to try, I strongly recommend doing an assessment of your own needs. After all, Flakes provides enough freedom and flexibility for us to flex our muscles, therefore, comprehensively reviewing your own usage scenarios and finding the architecture that best fits your current device scale is far more important than copying someone else’s template.
Here, let’s take a completely different yet quite common use case as an example. Suppose we have a very standard decoupled front-end and back-end application, and we want to use Flakes for its entire lifecycle of building, deployment, and management:
.
├── CLAUDE.md
├── flake.lock
├── flake.nix
├── README.md
├── secrets
└── servers
In this minimalist architecture designed specifically for a single business project, we have boldly stripped away complex abstractions like hosts, modules, and pkgs that are intended for cross device reuse and heterogeneous systems. Since there are no longer complex differences in environment and usage between personal workstations and multiple servers, all business logic can, and should be highly condensed.
In this new architecture, the roles of CLAUDE.md, flake.lock, flake.nix, and README.md remain exactly the same as before. Even the secrets directory is retained to store sensitive credentials encrypted by SOPS.
The real ingenuity of this minimalist architecture lies in its perfect separation of business code and infrastructure. In actual engineering practice, we would never stuff massive front-end and back-end business code entirely into this Flakes repository. Instead, we directly include the respective Git repositories of the front-end and back-end projects in the inputs of flake.nix. Through this repository level inclusion, our Flakes repository transforms into a pure control plane. No matter how rapidly the front-end and back-end source code iterates, we simply need to update flake.lock here, and Flakes can precisely pull the corresponding source code version and use its standardized outputs interface to complete reliable compilation and packaging.
As for the servers directory, it is specifically used for the flat management of target servers involved in actual deployment. Here, each server is an independent configuration declaration, we only need to call the compiled front-end and back-end artifacts introduced by the inputs earlier, and combine them with systemd or container configurations to easily complete the service deployment. Of course, the architecture always grows with the business. If future business scale expansion involves massive server nodes or complex container orchestration, we can further refine and design a hierarchical structure for the servers directory based on specific business scenarios or node roles.
Through this example, I want to illustrate that Flakes is never merely a simple collection of configuration files, nor is it some rigid dogma. Rather, it is a Lego engine that provides a high degree of precision and determinism. Whether you wanna build a refined project skyscraper or your own Cyber Night City, it is entirely up to yourself.
Practice
Now that we have a basic understanding of Flakes and have set up our own Flakes repository, let’s continue with the progress of our previous Nix deployment and see how this carefully designed architecture is actually put into practice in real operations.
...
├── hosts
│ ├── devices
│ │ ├── macbook-air
│ │ ├── shiny
│ │ └── ...
│ └── servers
...
Suppose we want to add our Flakes repository to the Debian workstation named Shiny:
Of course, you can also clone the repository first and then add it:
On macOS, we can use darwin-rebuild:
sudo darwin-rebuild switch --flake git+ssh://[email protected]/fernvenue/flakes.git#macbook-air
If it is a public platform like GitHub, it can even be abbreviated:
nix profile add github:fernvenue/flakes#macbook-air
In this way, Nix will automatically fetch the commit corresponding to flake.lock from the Git repository in the background, place it in the local cache, and then evaluate and install it. The entire process is elegant and seamless.
Analysis
So how does Flakes pick and choose, accurately stripping out the parts that Shiny and MacBook Air respectively need from this unified repository? This comes down to the standardized outputs contract defined in flake.nix. When we append target identifiers like #shiny or #macbook-air to the command, Flakes is effectively performing a precise addressing operation within its output attribute set. In my flake.nix, there is an architecture output like this:
outputs = { self, nixpkgs, darwin, home-manager, ... }@inputs: {
darwinConfigurations.macbook-air = darwin.lib.darwinSystem {
system = "aarch64-darwin";
modules = [
home-manager.darwinModules.home-manager
./hosts/devices/macbook-air
];
};
packages.x86_64-linux.shiny = import ./hosts/devices/shiny { ... };
...
};
In this rigorous tree structure, when we execute darwin-rebuild ...#macbook-air on the MacBook, Flakes accurately locates the darwinConfigurations.macbook-air node. Based on the specified aarch64-darwin architecture and the business logic under the ./hosts/devices/macbook-air directory, it generates the corresponding content for the MacBook.
Similarly, when we execute nix profile add .#shiny on Debian, Nix automatically detects the current system architecture and implicitly expands it to search for the build artifacts under the packages.x86_64-linux.shiny node.
Update
When our Flakes have changes and we need to synchronize the latest modifications on the MacBook, we just need to run darwin-rebuild again:
sudo darwin-rebuild switch --flake git+ssh://[email protected]/fernvenue/flakes.git#macbook-air
Nix sometimes caches remote Flakes for a period of time. We can force Nix to refresh its state using the --refresh parameter:
sudo darwin-rebuild switch --flake git+ssh://[email protected]/fernvenue/flakes.git#macbook-air --refresh
If it is a repository pulled to the local machine, we can continue to operate through git updates:
git pull
sudo darwin-rebuild switch --flake .#macbook-air
However, on Debian, we can no longer update using nix profile. Here we need to step back and re-examine an issue: neither macOS nor Debian are actually native declarative NixOS systems. Therefore, a simple nix profile cannot take over daemons and system services like launchd or systemd, nor can it directly modify core system configurations. Whether it is nix profile add or nix profile upgrade, they are essentially just high level symlink updaters, and their capability boundaries are limited to the binary level. They don’t access core system directories like /etc to complete further configuration modifications, nor do they handle secrets or system services for us. Considering this, we introduced nix-darwin on macOS, and darwin-rebuild is its core tool, a macOS replica of nixos-rebuild on NixOS, designed to extend the philosophy of Infrastructure as Code to macOS. This is also why, when introducing the macOS part, I used darwin-rebuild from the beginning rather than nix profile.
In other words, we can use nixos-rebuild on NixOS and darwin-rebuild on macOS. But what about Debian? Actually, no Linux distribution other than NixOS has a similar native system level tool. However, we do have nix-community/home-manager. In my Flakes, Home Manager does play a critical role, acting like an excellent butler that keeps everything at the user level in perfect order. At the system level, the Nix community has introduced the ambitious numtide/system-manager, attempting to allow non-NixOS nodes to manage system level content declaratively using pure Nix modules.
Despite this, during the practical process, I also discovered that there is a natural gap between the Nix environment and non-NixOS distributions like Debian, namely, that they each possess their own independent glibc. When dealing with configurations that are highly dependent on the underlying host state, such as time zones and locale, it creates an irreconcilable sense of fragmentation. As a framework with a strict schema, system-manager naturally won’t lower itself to handle this kind of dirty work that requires compromising downwards. Of course, you can understand this as a philosophical difference. I believe that providing external compatibility for a relatively stable distribution like Debian doesn’t compromise the reproducibility of Flakes. Therefore, based on Home Manager, I have built my own encapsulation layer:
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
home-manager.url = "github:nix-community/home-manager";
};
outputs = { self, nixpkgs, home-manager }@inputs:
let
system = "x86_64-linux";
pkgs = import nixpkgs { inherit system; };
mkSystemConfig = pkgs: ''
echo "Asia/Hong_Kong" > /etc/timezone
/bin/ln -sf /usr/share/zoneinfo/Asia/Hong_Kong /etc/localtime
/usr/sbin/locale-gen
'';
mkActivateScript = pkgs: name: body:
pkgs.writeShellScript name ''
set -e
${mkSystemConfig pkgs}
${body}
systemctl list-units --failed
'';
in {
packages.${system}.example = pkgs.hello;
apps.${system}.example-activate = {
type = "app";
program = toString (mkActivateScript pkgs "example-activate" ''
nix profile upgrade -vvv --print-build-logs example
${pkgs.home-manager}/bin/home-manager switch --flake .#"fernvenue@example" -b backup
'');
};
};
}
First, we defined the mkSystemConfig function, which leverages Nix’s powerful string interpolation to directly write down the Bash operations that require root privileges, ensuring they are always reproducible on the current Debian Stable. Subsequently, the mkActivateScript function uses pkgs.writeShellScript to package these actions along with the body business logic we pass in into a standard executable script, and it forces the set -e option to ensure that if any step fails, the entire process is terminated immediately. Finally, in the example-activate exposed by the apps interface, we use nix profile to update the binary and gracefully call ${pkgs.home-manager}/bin/home-manager to execute the switch, passing the baton to the user level butler. Based on the above implementation, the update on Debian only requires:
git pull
nix run .#example-activate
As a side note, I’m actually already conceiving of further abstraction and encapsulation. After repeated verification, I plan to distill this logic into debian-rebuild tailored for Debian Stable, making it truly resonate at the same frequency as the Nix philosophy.
Other
Earlier in the text, following the thread of Flakes, we clarified the most basic yet most important aspects: deployment, design, and updates. However, in actual practice, there are various other use cases. Here, I will simply select two as a starting point to spark further ideas, which will also serve as the conclusion of this article.
Importing External Repositories
Since the core idea of Flakes is to build a precise and flawless dependency graph, its inputs nodes naturally can’t be limited to just nixpkgs or home-manager. In daily practice, we inevitably need to import some traditional open source projects that don’t provide Nix support, as well as include private repositories and business code belonging to ourselves or our team. Thanks to Nix’s native support for Git, introducing these external dependencies is quite straightforward.
- Importing Private Repositories
Whether they are private repositories hosted on GitHub/GitLab or a self-hosted Git service, we can directly use git+ssh:// to include them:
inputs = {
example = {
url = "git+ssh://[email protected]/fernvenue/example.git";
inputs.nixpkgs.follows = "nixpkgs";
};
...
};
Nix will directly call your SSH Agent for authentication, making the entire process completely transparent and eliminating the need to hardcode any credentials.
As a side note, appending ?submodules=1 to the end of the url allows Nix to fetch and lock all dependencies together for us.
If you need to avoid certain breaking updates from upstream, or lock a specific snapshot version, Flakes also allows us to write the specific hash directly into the url:
example = {
url = "git+ssh://[email protected]/fernvenue/example.git?rev=141840...1492740";
inputs.nixpkgs.follows = "nixpkgs";
};
- Importing Non-Nix Repositories
This is a very powerful feature of Flakes. Not all projects provide a written flake.nix in their root directory, such as when we want to import JackHack96/EasyEffects-Presets:
easyeffects-presets = {
url = "github:JackHack96/EasyEffects-Presets";
flake = false;
};
By simply adding the flake = false; declaration, Nix will no longer look for a Flakes configuration file. Instead, it will fetch the entire Git repository untouched, placing it into a read-only cache as a pure static source code directory. Subsequently, we can directly invoke or compile it using this source code path in other functions within outputs. In this way, no matter how scattered or non-standardized the external resources are, their exact hash values will be computed and locked in flake.lock, allowing them to be seamlessly integrated into our infrastructure.
Secrets Management
Placing secrets in a code repository is a major taboo in software engineering, and Infrastructure as Code is no exception. Furthermore, in the Nix architecture, the Nix Store is globally readable by default. Even if you pass in a password locally using a private file, as long as it participates in the Nix build evaluation, the plaintext password will eventually be exposed in the /nix/store directory in a read-only format, and any unprivileged user on the system can easily obtain it.
To address this use case without compromising the declarative philosophy, I introduced SOPS to help me securely manage sensitive information. I have never liked the bloated nature of GPG, so I chose Age, a modern, concise, and secure tool.
First, generate an Age key pair using age-keygen:
mkdir -p ~/.config/sops/age
age-keygen -o /root/.config/sops/age/keys.txt
Then, you will get a public key starting with age1. With this public key, we can create and configure the .sops.yaml rules in the root directory of the Flakes repository:
creation_rules:
- path_regex: secrets/.*\.yaml$
key_groups:
- age:
- age1...
If you use a YubiKey like I do, age-plugin-yubikey is an unmissable choice.
In previous sections, I also mentioned that my secrets directory is flattened, but to be precise, it is a nested hierarchy based on a flattened structure:
.
└── secrets
├── example.json
├── common
│ ├── example.yml
└── server01
└── example.txt
The corresponding .sops.yaml is:
keys:
- &yubikey1 age1...
- &yubikey2 age1...
- &server1 age1...
- &server2 age1...
creation_rules:
- path_regex: secrets/server1/.*$
key_groups:
- age:
- *yubikey1
- *yubikey2
- *server1
- path_regex: secrets/server2/.*$
key_groups:
- age:
- *yubikey1
- *yubikey2
- *server2
- path_regex: secrets/common/.*$
key_groups:
- age:
- *yubikey1
- *yubikey2
- *server1
- *server2
- path_regex: secrets/.*$
key_groups:
- age:
- *yubikey1
- *yubikey2
The reason it is called a flattened nested hierarchy is that its design is based entirely on the unlocking level of the keys themselves. From the .sops.yaml above, it’s easy to see that the higher the directory, the more restricted its unlocking permissions become, allowing decryption only over the two YubiKeys I hold. As the directory extends downwards, the unlocking permissions are expanded horizontally, allowing decryption either using the YubiKeys I hold or by the respective device itself.
By the way, SOPS evaluates rules from top to bottom and stops at the first match. This means you must place the broad root directory fallback rule at the very end and the more specific, deeper directory rules at the beginning. Combined with our flattened nested hierarchy design and YAML’s anchor and alias syntax, the entire secrets topology appears extremely elegant.