building declarative userspace
Creating a declarative workspace with home-manager
A bit of context
Nix Explained Simply:
Nix is a tool that installs and manages software in a way that avoids conflicts and ensures reproducibility. Here’s how it works:
No More Conflicts: Every software package (like Python, Node.js, etc.) is stored in a unique folder with a special ID. This means you can have multiple versions of the same software without them interfering. Imagine each app living in its own isolated "bubble" so they never clash.
Reproducible Setups: You define exactly what software and versions you need in a simple config file (like a recipe). Nix uses this to install everything correctly. Share this file with others, and they’ll get the exact same setup—great for teamwork or moving between computers.
Rollbacks Made Easy: If an update breaks something, Nix lets you instantly revert to a previous working version, like a time machine for your software.
Example: Need both Python 2 and 3? Nix installs them side-by-side. Share your config, and your friend’s setup will match yours perfectly.
Think of it as a declarative, bulletproof package manager that keeps your system clean and predictable. 🛠️✨
Thanks deepseek.
You can use Nix as a "normal" package manager like apt but its true power shines when using it to write .nix
files to create easy to deploy declarative and reproducible environments.
Nix has a tool called home-manager, that allows users to describe their user-environment declaratively (aka. "define-once, reuse everywhere"). You can install packages, programs, systemd services (Background processes, like auto-starting apps) and more by simply writing them into a configuration file. Furthermore, you can customize and generate configs for programs and services from within this single file.
This setup uses two files to ensure customizability and avoid reliance on preinstalled Nix channels.
But why?
For some this question might have already come up, so let me give you an example. You have a laptop and a workstation. You use both to do the exact same or similar tasks. So you need your "core" Software. (Discord, firefox, codium,... )
I will assume you use Linux because why else would you read about a niche and "hard" (steep learning-curve) package manager setup.
So you spent time to customize your workstation, maybe even build your own dotfiles for your window manager.
installed all your software, configured it and then you go to your laptop and do basically the exact same thing.
You may have put your dotfiles in a git repo to at least not have to write all that again but your packages?
Maybe some software puts its configs in a weird folder that's not .config
and you forgot it.
you make all your theme choices again and you spend up to an hour doing that.
The next day you make an update and the laptop bricks.
No rollback.
Just start over.
Sounds annoying? Yes.
Is it preventable on arch? If you update late. Probably.
Nix gives you the ability to declare all setup once and you can take it everywhere. (also on Mac!)
From the home.nix
it creates config files from the declaration you made.
Leading to an easy setup and basically no config time on subsequent devices.
you could write setup scripts in bash, but those can break by updates to packages renamed packages and they are annoying to debug.
It gives you rollbacks, test builds that don't go into effect instantly and can be run in a vm with just a flag. And for me it fixes one of my biggest problems. Having packages installed that I:
- don't know I installed
- i forgot to uninstall
- the constant feeling of having bloated my entire system within two days
enable flakes and the nix command (if you haven't already)
echo "experimental-features = nix-command flakes" >> ~/.config/nix/nix.conf
or if you if you have NixOS you can declare it in the /etc/nixos/configuration.nix
by adding nix.settings.experimental-features =[ "nix-command" "flakes"]
.
Configuration
Flake setup
We'll start with the shorter and easier file: flake.nix
.
Flakes are Nix’s way to define reproducible builds with explicit dependencies, ensuring your setup works identically everywhere. Or its a "it just works." file
This file sets up the environment for my actual home.nix
(the home-manager configuration file).
Explaining
Let's examine this from the top:
These are my inputs. They describe what flakes/channels I need and where to get them from. Zen browser doesn't have a package in the standard channels, so I added it via a flake from another GitHub repository. This ensures that wherever I run this configuration, it always knows what it needs.
inputs = {
# Stable channel
nixpkgs.url = "github:nixos/nixpkgs/nixos-24.11";
# Home Manager
home-manager = {
url = "github:nix-community/home-manager/release-24.11";
inputs.nixpkgs.follows = "nixpkgs";
};
# Zen Browser flake
zen-browser = {
url = "github:MarceColl/zen-browser-flake";
inputs.nixpkgs.follows = "nixpkgs";
};
# Unstable channel
nixpkgs-unstable.url = "github:nixos/nixpkgs/nixos-unstable";
};
Next, I output the declarations from above and combine them with the inputs using @:
think of it like pipe-ing outputs into the inputs for the next section.
outputs = { nixpkgs, home-manager, zen-browser, nixpkgs-unstable, ... } @
inputs:
In the let
section, I configure the packages to allow unfree and broken packages. I also configure home.packages
to install zen-browser.
Don't forget the target system specification, which allows the config to be evaluated for one or multiple targets. While I only need Linux, others might use this to maintain one configuration for both their Linux and Mac systems.
let
# System configuration
system = "x86_64-linux";
lib = nixpkgs.lib;
# Package sets
pkgs = import nixpkgs {
inherit system;
config = {
allowUnfree = true;
allowBroken = true;
};
};
unstablePkgs = import nixpkgs-unstable {
inherit system;
config = {
allowUnfree = true;
allowBroken = true;
};
};
# Common configuration for Home Manager
baseConfig = {
home.packages = with pkgs; [
inputs.zen-browser.packages.${system}.default
];
nixpkgs.config = {
allowUnfree = true;
allowBroken = true;
};
};
Then I pass the arguments from above to my home.nix. You can create multiple home.nix files for different purposes (e.g., gaming or programming) to change your setup accordingly.
in {
homeConfigurations.myprofile = home-manager.lib.homeManagerConfiguration {
inherit pkgs;
extraSpecialArgs = {
inherit inputs unstablePkgs;
};
modules = [
({ config, ... }: baseConfig)
./home.nix
];
};
};
In full:
{
description = "My Home Manager configuration";
inputs = {
# Stable channel
nixpkgs.url = "github:nixos/nixpkgs/nixos-24.11";
# Home Manager
home-manager = {
url = "github:nix-community/home-manager/release-24.11";
inputs.nixpkgs.follows = "nixpkgs";
};
# Zen Browser flake
zen-browser = {
url = "github:MarceColl/zen-browser-flake";
inputs.nixpkgs.follows = "nixpkgs";
};
# Unstable channel
nixpkgs-unstable.url = "github:nixos/nixpkgs/nixos-unstable";
};
outputs = { nixpkgs, home-manager, zen-browser, nixpkgs-unstable, ... } @ inputs:
let
# System configuration
system = "x86_64-linux";
lib = nixpkgs.lib;
# Package sets
pkgs = import nixpkgs {
inherit system;
config = {
allowUnfree = true;
allowBroken = true;
};
};
unstablePkgs = import nixpkgs-unstable {
inherit system;
config = {
allowUnfree = true;
allowBroken = true;
};
};
# Common configuration for Home Manager
baseConfig = {
home.packages = with pkgs; [
inputs.zen-browser.packages.${system}.default
];
nixpkgs.config = {
allowUnfree = true;
allowBroken = true;
};
};
in {
homeConfigurations.myprofile = home-manager.lib.homeManagerConfiguration {
inherit pkgs;
extraSpecialArgs = {
inherit inputs unstablePkgs;
};
modules = [
({ config, ... }: baseConfig)
./home.nix
];
};
};
}
home.nix
Now we get to the actual home-manager configuration.
Explaining
Here I configure my username, home directory, and state version:
# ========== Basic Configuration ==========
home.username = "dev";
home.homeDirectory = "/home/dev";
home.stateVersion = "24.11";
programs.home-manager.enable = true;
These are my packages for daily use. The unstable packages are utilities I want in their most up-to-date versions.
Note: unstable packages can create problems because they are, well, unstable. use them only if necessary or you really need the latest version.
home.packages = with pkgs; [
unstablePkgs.jujutsu
btop
dwarf-fortress
unstablePkgs.vscodium
unstablePkgs.minecraft
unstablePkgs.radicle-node
gnumake
gh
unstablePkgs.rustup
unstablePkgs.rustc
zed-editor
keepass
tuisky
typst
obsidian
tt
nix-init
## filemanager dependencies
ranger
# Optional dependencies for better functionality
w3m # For image previews
ffmpegthumbnailer # For video thumbnails
poppler_utils # For PDF previews (pdftotext)
highlight # For syntax highlighting
atool # For archive previews
mediainfo # For media file info
];
Here is my file manager setup, which lets me use Ranger directly from Rofi:
# ========== Ranger file manager ==========
# Create desktop entry
xdg.desktopEntries.ranger = {
name = "Ranger";
genericName = "File Manager";
comment = "Terminal-based file manager with VI key bindings";
exec = "${pkgs.kitty}/bin/kitty -e ranger %F"; # Changed %U to %F
terminal = false; # Important change (explained below)
categories = [ "FileManager" "Utility" ]; # Simplified categories
mimeType = [ "inode/directory" ]; # Removed GNOME-specific type
icon = "utilities-terminal";
startupNotify = false; # Added for better behavior
noDisplay = false; # Ensures it shows in menus
};
xdg.mimeApps = {
enable = true;
defaultApplications = {
"inode/directory" = [ "ranger.desktop" ];
};
};
# Set MIME associations manually
xdg.configFile."mimeapps.list".text = ''
[Default Applications]
inode/directory=ranger.desktop
application/x-gnome-saved-search=ranger.desktop
'';
home.activation.setRangerAsDefault = ''
${pkgs.xdg-utils}/bin/xdg-mime default ranger.desktop inode/directory
'';
# Ranger configuration
xdg.configFile."ranger/rc.conf".text = ''
set show_hidden true
set preview_images true
set preview_images_method w3m
set sort natural
'';
My Starship config is quite elaborate. It's a baduk themed shell and looks really nice It's not just for show. Starship shows you your projects git status which is really useful when coding:
# ========== Starship Prompt ==========
programs.starship = {
enable = true;
settings = {
# Core Formatting
add_newline = false;
format = "$username$hostname$nix_shell$git_branch$git_commit$git_state$git_status$directory$jobs $cmd_duration$character";
# Baduk Git Theme
git_branch = {
format = ''[●○ @ \($branch\)]($style)'';
style = "bold";
symbol = "";
};
git_status = {
conflicted = "✖"; # Atari!
ahead = "▲"; # Winning
behind = "▼"; # Losing
diverged = "⏣"; # Seki (stalemate)
staged = "○"; # White stone (staged)
modified = "●"; # Black stone (unstaged)
untracked = "◻"; # Empty intersection
stashed = "≡"; # Ko fight
style = "dimmed";
};
directory = {
format = ''[\($path\)]($style)'';
style = "blue dimmed";
truncation_length = 3;
substitutions = {
"Documents" = "D";
"Projects" = "P";
"Downloads" = "DL";
};
};
# User/System Info
username = {
disabled = false;
style_user = "bright-white bold";
style_root = "bright-red bold";
format = "[$user]($style)";
show_always = true;
};
hostname = {
disabled = false;
format = "[@$hostname]($style)";
style = "bright-green bold";
};
# Shell/System
shell = {
disabled = false;
format = "$indicator";
fish_indicator = ''[●⋉](bold)'';
bash_indicator = ''[🌀](bold #9a77cf)'';
};
cmd_duration = {
format = ''[$duration]($style)'';
style = "yellow";
min_time = 5000;
};
# Optional: Nix Shell Indicator
nix_shell = {
disabled = false;
format = "[via $name]($style)";
style = "bold purple";
};
};
};
I'm configuring ssh-agent here so that Radicle can use rad auth
properly.
ssh-agent is a systemd service (background processes managed by systemd):
# ========== SSH Agent ==========
programs.ssh = {
enable = true;
extraConfig = ''
AddKeysToAgent yes
IdentityAgent ${config.home.homeDirectory}/.ssh/agent.sock
'';
};
# Systemd service with correct syntax
systemd.user.services.ssh-agent = {
Unit = {
Description = "SSH Authentication Agent";
Documentation = "man:ssh-agent(1)";
};
Service = {
Type = "simple";
ExecStart = ''${pkgs.openssh}/bin/ssh-agent -D -a ${config.home.homeDirectory}/.ssh/agent.sock '';
Restart = "on-failure";
};
Install = {
WantedBy = [ "default.target" ];
};
};
# Environment variables
home.sessionVariables = {
SSH_AUTH_SOCK = "${config.home.homeDirectory}/.ssh/agent.sock";
};
# Create directory and ensure proper permissions
home.activation.setupSshAgent = lib.hm.dag.entryAfter ["writeBoundary"] ''
mkdir -p ${config.home.homeDirectory}/.ssh
chmod 700 ${config.home.homeDirectory}/.ssh
'';
Finally, here's my Zed editor configuration. Note that the fileManager setting doesn't currently work, and I've only tested the Rust LSP (Language Server Protocol) - the others might need adjustments.
LSP provides code completion/linting. You can also create snippets in the LSP section. check the zed docs for information.
# ========== Zed Editor ==========
programs.zed-editor = {
enable = true;
extensions = ["nix" "toml" "elixir" "make" "sql"];
userSettings = {
fileManager = "ranger";
assistant = {
enabled = true;
version = "2";
default_open_ai_model = null;
};
# Node.js Configuration
node = {
path = lib.getExe pkgs.nodejs;
npm_path = lib.getExe' pkgs.nodejs "npm";
};
# General Settings
hour_format = "hour24";
auto_update = false;
vim_mode = false;
load_direnv = "shell_hook";
base_keymap = "VSCode";
# Terminal Configuration
terminal = {
alternate_scroll = "off";
blinking = "off";
copy_on_select = false;
dock = "bottom";
detect_venv = {
on = {
directories = [".env" "env" ".venv" "venv"];
activate_script = "default";
};
};
env = { TERM = "kitty"; };
font_family = "FiraCode Nerd Font";
font_features = null;
font_size = null;
line_height = "comfortable";
option_as_meta = false;
button = false;
shell = "system";
toolbar = { title = true; };
working_directory = "current_project_directory";
};
# Language Server Protocol (LSP) Configuration
lsp = {
rust-analyzer = {
binary = {
path = lib.getExe pkgs.rust-analyzer;
path_lookup = true;
};
};
nix = {
binary.path_lookup = true;
};
elixir-ls = {
binary.path_lookup = true;
settings.dialyzerEnabled = true;
};
};
# Language-specific Settings
languages = {
"Elixir" = {
language_servers = ["!lexical" "elixir-ls" "!next-ls"];
format_on_save = {
external = {
command = "mix";
arguments = ["format" "--stdin-filename" "{buffer_path}" "-"];
};
};
};
"HEEX" = {
language_servers = ["!lexical" "elixir-ls" "!next-ls"];
format_on_save = {
external = {
command = "mix";
arguments = ["format" "--stdin-filename" "{buffer_path}" "-"];
};
};
};
};
# UI Configuration
theme = {
mode = "dark";
light = "One Light";
dark = "One Dark";
};
show_whitespaces = "all";
ui_font_size = 16;
buffer_font_size = 16;
};
};
The full file:
{ config, pkgs, unstablePkgs, lib, ... }:
{
# ========== Basic Configuration ==========
home.username = "dev";
home.homeDirectory = "/home/dev";
home.stateVersion = "25.05";
programs.home-manager.enable = true;
# ========== Package List ==========
home.packages = with pkgs; [
unstablePkgs.jujutsu
btop
dwarf-fortress
unstablePkgs.vscodium
unstablePkgs.minecraft
unstablePkgs.radicle-node
gnumake
gh
unstablePkgs.rustup
unstablePkgs.rustc
zed-editor
keepass
tuisky
typst
obsidian
tt
nix-init
## filemanager dependencies
ranger
# Optional dependencies for better functionality
w3m # For image previews
ffmpegthumbnailer # For video thumbnails
poppler_utils # For PDF previews (pdftotext)
highlight # For syntax highlighting
atool # For archive previews
mediainfo # For media file info
];
# ========== Ranger file manager ==========
# Create desktop entry
xdg.desktopEntries.ranger = {
name = "Ranger";
genericName = "File Manager";
comment = "Terminal-based file manager with VI key bindings";
exec = "${pkgs.kitty}/bin/kitty -e ranger %F"; # Changed %U to %F
terminal = false; # Important change (explained below)
categories = [ "FileManager" "Utility" ]; # Simplified categories
mimeType = [ "inode/directory" ]; # Removed GNOME-specific type
icon = "utilities-terminal";
startupNotify = false; # Added for better behavior
noDisplay = false; # Ensures it shows in menus
};
xdg.mimeApps = {
enable = true;
defaultApplications = {
"inode/directory" = [ "ranger.desktop" ];
};
};
# Set file type associations manually
xdg.configFile."mimeapps.list".text = ''
[Default Applications]
inode/directory=ranger.desktop
application/x-gnome-saved-search=ranger.desktop
'';
home.activation.setRangerAsDefault = ''
${pkgs.xdg-utils}/bin/xdg-mime default ranger.desktop inode/directory
'';
# Ranger configuration
xdg.configFile."ranger/rc.conf".text = ''
set show_hidden true
set preview_images true
set preview_images_method w3m
set sort natural
'';
# ========== Starship Prompt ==========
programs.starship = {
enable = true;
settings = {
# Core Formatting
add_newline = false;
format = "$username$hostname$nix_shell$git_branch$git_commit$git_state$git_status$directory$jobs $cmd_duration$character";
# Baduk Git Theme
git_branch = {
format = ''[●○ @ \($branch\)]($style)'';
style = "bold";
symbol = "";
};
git_status = {
conflicted = "✖"; # Atari!
ahead = "▲"; # Winning
behind = "▼"; # Losing
diverged = "⏣"; # Seki (stalemate)
staged = "○"; # White stone (staged)
modified = "●"; # Black stone (unstaged)
untracked = "◻"; # Empty intersection
stashed = "≡"; # Ko fight
style = "dimmed";
};
directory = {
format = ''[\($path\)]($style)'';
style = "blue dimmed";
truncation_length = 3;
substitutions = {
"Documents" = "D";
"Projects" = "P";
"Downloads" = "DL";
};
};
# User/System Info
username = {
disabled = false;
style_user = "bright-white bold";
style_root = "bright-red bold";
format = "[$user]($style)";
show_always = true;
};
hostname = {
disabled = false;
format = "[@$hostname]($style)";
style = "bright-green bold";
};
# Shell/System
shell = {
disabled = false;
format = "$indicator";
fish_indicator = ''[●⋉](bold)'';
bash_indicator = ''[🌀](bold #9a77cf)'';
};
cmd_duration = {
format = ''[$duration]($style)'';
style = "yellow";
min_time = 5000;
};
# Optional: Nix Shell Indicator
nix_shell = {
disabled = false;
format = "[via $name]($style)";
style = "bold purple";
};
};
};
# ========== SSH Agent ==========
programs.ssh = {
enable = true;
extraConfig = ''
AddKeysToAgent yes
IdentityAgent ${config.home.homeDirectory}/.ssh/agent.sock
'';
};
# Systemd service with correct syntax
systemd.user.services.ssh-agent = {
Unit = {
Description = "SSH Authentication Agent";
Documentation = "man:ssh-agent(1)";
};
Service = {
Type = "simple";
ExecStart = ''${pkgs.openssh}/bin/ssh-agent -D -a ${config.home.homeDirectory}/.ssh/agent.sock '';
Restart = "on-failure";
};
Install = {
WantedBy = [ "default.target" ];
};
};
# Environment variables
home.sessionVariables = {
SSH_AUTH_SOCK = "${config.home.homeDirectory}/.ssh/agent.sock";
};
# Create directory and ensure proper permissions
home.activation.setupSshAgent = lib.hm.dag.entryAfter ["writeBoundary"] ''
mkdir -p ${config.home.homeDirectory}/.ssh
chmod 700 ${config.home.homeDirectory}/.ssh
'';
# ========== Zed Editor ==========
programs.zed-editor = {
enable = true;
extensions = ["nix" "toml" "elixir" "make" "sql"];
userSettings = {
fileManager = "ranger";
assistant = {
enabled = true;
version = "2";
default_open_ai_model = null;
};
# Node.js Configuration
node = {
path = lib.getExe pkgs.nodejs;
npm_path = lib.getExe pkgs.nodejs "npm";
};
# General Settings
hour_format = "hour24";
auto_update = false;
vim_mode = false;
load_direnv = "shell_hook";
base_keymap = "VSCode";
# Terminal Configuration
terminal = {
alternate_scroll = "off";
blinking = "off";
copy_on_select = false;
dock = "bottom";
detect_venv = {
on = {
directories = [".env" "env" ".venv" "venv"];
activate_script = "default";
};
};
env = { TERM = "kitty"; };
font_family = "FiraCode Nerd Font";
font_features = null;
font_size = null;
line_height = "comfortable";
option_as_meta = false;
button = false;
shell = "system";
toolbar = { title = true; };
working_directory = "current_project_directory";
};
# Language Server Protocol (LSP) Configuration
lsp = {
rust-analyzer = {
binary = {
path = lib.getExe pkgs.rust-analyzer;
path_lookup = true;
};
};
nix = {
binary.path_lookup = true;
};
elixir-ls = {
binary.path_lookup = true;
settings.dialyzerEnabled = true;
};
};
# Language-specific Settings
languages = {
"Elixir" = {
language_servers = ["!lexical" "elixir-ls" "!next-ls"];
format_on_save = {
external = {
command = "mix";
arguments = ["format" "--stdin-filename" "{buffer_path}" "-"];
};
};
};
"HEEX" = {
language_servers = ["!lexical" "elixir-ls" "!next-ls"];
format_on_save = {
external = {
command = "mix";
arguments = ["format" "--stdin-filename" "{buffer_path}" "-"];
};
};
};
};
# UI Configuration
theme = {
mode = "dark";
light = "One Light";
dark = "One Dark";
};
show_whitespaces = "all";
ui_font_size = 16;
buffer_font_size = 16;
};
};
}
That's it!
running and troubleshooting tips
you switch to the new config with home-manager switch --flake .#myprofile
.
If any errors show up and it doesn't build.
- Google around.
- Ask the forum (they are really nice).
- Or just ask your AI of choice.
if your switch did work but it created a broken command or other bug run home-manager generations
.
Take the latest generations path.
add /activate
to the end.
And with that you should be back at the last generation.
From there you should go and investigate the unstable packages first.
With nix.shell
or just wait a week and try again.
If you want to see the up-to-date version of these files, check out this Git repository. The config is constantly changing. Use at your own risk. (also there is a Makefile in there for those who dont wanna remember the whole command)
You made it. If you want to reach out consider following me on bsky @doxx.casino