Users, Groups, and SSH Configuration
Users, Groups, and SSH Configuration
useradd on a server is an account git does not know about. The boring default is: users.mutableUsers = false, SSH keys in the flake, password hashes from mkpasswd or a file, and sshd with password auth off.
Mental model
With mutableUsers = false, every nixos-rebuild switch rewrites /etc/passwd and /etc/shadow from the config. An account that left the flake left the machine. passwd as a user may appear to work until the next switch (or be blocked). That is the feature.
| Field | Use |
|---|---|
isNormalUser = true; |
Human: home, shell, extraGroups |
isSystemUser = true; |
Daemon uid you named (rare if DynamicUser suffices) |
extraGroups = [ "wheel" ]; |
sudo (if sudo is enabled) |
openssh.authorizedKeys.keys |
Login without a password |
hashedPassword |
Hash in the store (still not plaintext) |
hashedPasswordFile |
Hash outside the store (better) |
initialPassword |
Lab only. Plaintext in the store. Never production. |
flake → users.users.deskadmin
├── uid/gid
├── ~/.ssh/authorized_keys (generated)
└── hashedPasswordFile
services.openssh is the daemon. User keys do not start sshd. Enable the service, then disable password authentication.
Worked examples
Case 1: Immutable users and hardened sshd
Save as users_ssh.nix:
# users_ssh.nix
{
users.mutableUsers = false;
users.users.deskadmin = {
isNormalUser = true;
extraGroups = [ "wheel" ];
openssh.authorizedKeys.keys = [
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDeskAdminLaptop deskadmin@laptop"
];
};
users.users.deployer = {
isNormalUser = true;
description = "CI deploy key";
extraGroups = [ "wheel" ];
openssh.authorizedKeys.keys = [
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExampleDeployerKeyCorp"
];
};
services.openssh = {
enable = true;
settings = {
PasswordAuthentication = false;
KbdInteractiveAuthentication = false;
PermitRootLogin = "no";
X11Forwarding = false;
};
};
security.sudo.wheelNeedsPassword = true;
}security.sudo.wheelNeedsPassword = false is convenient on a lab VM and a foot-gun on a laptop someone might walk away from. Default to needing a password (or a key + sudo timestamp) unless this is a CI image.
sudo nixos-rebuild switch
getent passwd deskadmin
sudo grep -E 'PasswordAuthentication|PermitRootLogin' /etc/ssh/sshd_configCase 2: Hash a console password
On a trusted machine:
mkpasswd -m sha-512Output (shape):
$6$rounds=656000$SALT$HASH…
# password_hash.nix
{
users.users.operator = {
isNormalUser = true;
hashedPassword = "$6$rounds=656000$SALT$HASH";
};
}The hash is in the store. That is weaker than hashedPasswordFile but not the same as initialPassword = "hunter2". Anyone with store access can offline-crack the hash; they do not see the password in grep. Prefer a file:
{
users.users.operator.hashedPasswordFile = "/persist/secrets/operator.hash";
}Generate the file once, mode 0400, persist it (impermanence). sops-nix can decrypt it at boot.
Case 3: Root is not a daily login
# root.nix
{
users.users.root.openssh.authorizedKeys.keys = [ ];
services.openssh.settings.PermitRootLogin = "no";
}Break-glass in a lab VM can use a root password hash on the console, not over SSH. Production: serial console + the previous generation, not PermitRootLogin = "yes".
Case 4: Groups for devices, not for folklore
# groups.nix
{
users.users.deskadmin.extraGroups = [
"wheel"
"networkmanager" # if NM is enabled
"video"
"dialout" # USB serial, lab only
];
}Each group is a capability. extraGroups = [ "wheel" "docker" "libvirtd" "adbusers" "…"] on every human is how laptops become root. Add groups when a device or module requires them.
users.groups.desk.members = [ "deskadmin" "deployer" ]; for a shared directory.
Case 5: Prove password SSH is dead
From another machine:
ssh -o PreferredAuthentications=password -o PubkeyAuthentication=no deskadmin@desk-workstationOutput:
deskadmin@desk-workstation: Permission denied (publickey).
Then:
ssh -i ~/.ssh/id_ed25519 deskadmin@desk-workstation trueOutput: silence, exit 0.
If password still works, PasswordAuthentication did not land (wrong settings. attr on this release) or you are not hitting this sshd.
# ssh-allow.nix fragment
{
services.openssh.settings.AllowUsers = [ "deskadmin" "deployer" ];
services.openssh.ports = [ 22 ];
}AllowUsers is a second gate after keys. User systemd (loginctl enable-linger deskadmin) is not implied by isNormalUser — set users.users.deskadmin.linger = true; only if that user must run lingering systemd --user units after logout (CI agents, not laptops).
The trap
The trap is initialPassword = "changeme" left in the flake after install. The string is in /nix/store forever (and in git history). Even after you “change” it with passwd, mutableUsers = false may reset or ignore that, and the store still has changeme.
Use hashedPassword / hashedPasswordFile. Delete initialPassword from the first commit that leaves the lab.
The other trap is users.mutableUsers = true on a server “so the new hire can passwd.” They never appear in git. Six months later nobody can say who has a shell. false plus a PR that adds their key.
The boring rule
mutableUsers = falseon anything you would call production (and on the lab VM, so you practise).- SSH keys in the flake. Password SSH off. Root SSH off.
- Hashes from
mkpasswd -m sha-512. PreferhashedPasswordFile. - Never
initialPasswordoutside a throwaway installer snippet. wheelis sudo. Do not sprinkle extra groups “just in case.”AllowUserson sshd.lingeronly for user units that must outlive the session.
Try this
- Run
mkpasswd -m sha-512, paste the hash on a lab user, switch, log in on the VM console. - Add a second authorized key, switch,
ssh-add -l/ connect with that key. Remove the key, switch, confirm that key is refused. sudo useradd leftoveron amutableUsers = falsehost, thensudo nixos-rebuild switch, thengetent passwd leftover. The account should be gone.nix-store --query --references /run/current-system | xargs grep -l initialPassword— should print nothing. If it prints, you still have plaintext in the closure.