Manage your dotfiles¶
Your shell config, your editor settings, your ~/.config tree: declared in one file, applied with one command, and undoable in one second.
That last part is the reason to bother. Copying files into $HOME is easy; every dotfile manager does it. No dotfile manager lets you say "put it back the way it was before I changed it" and have that be a single, exact, guaranteed operation. Punix does, because a dotfile deploy is an ordinary Punix generation; the same mechanism rolls back a server.
No root, and nothing new to learn
This uses scope = "user", which writes under your home directory as you. There is no separate dotfiles feature: it is the same config surface, the same deploy command, and the same rollback you would use for a web server. If you have read your first deploy, you already know all of it.
Five minutes¶
Create dotfiles.pcl. Paths are relative to your home directory. No username, no /Users vs /home: the same file works on every machine you own.
module Dotfiles {
backend = "systemd"
scope = "user"
configFiles = [
{ path = ".config/myapp/settings.conf"
content = "theme = dark\nfont = 13\n" }
]
}
Apply it:
The file is there:
No flags, no root, no daemon. punix recorded the whole thing as generation 1 under ~/.punix/deployments/.
The part that matters: undo¶
Change something and deploy again:
$ punix service deploy Dotfiles --file dotfiles.pcl
deployed: stack Dotfiles → gen-002 (1 config file(s), 0 store path(s) pinned)
Now decide you preferred it before:
$ punix service rollback Dotfiles
rolled back: stack Dotfiles → gen-001 (was gen-002) [1 config file(s) byte-restored, 0 removed]
$ cat ~/.config/myapp/settings.conf
theme = dark
font = 13
Byte-exact, and it did not re-read your .pcl file. Generation 1 carries the actual bytes it wrote, so rollback works even if you deleted the source, edited it beyond recognition, or are on a plane. It is a file copy and a symlink flip. There is no "re-apply and hope".
punix service rollback steps back exactly one generation per call. To see what you have, look at the records directly, since there is no listing command for service stacks yet:
$ ls ~/.punix/deployments/Dotfiles/
current gen-001.json gen-002.json
$ readlink ~/.punix/deployments/Dotfiles/current
gen-001.json
Each gen-NNN.json is a complete, greppable record of that deploy: every file's path, its SHA-256, and the bytes needed to restore it.
Real files in a real repo¶
Inlining a dotfile as a content string works, but a 3 KB ~/.config/git/ignore becomes one enormous escaped line you can't review or merge. Point at the file instead:
module Dotfiles {
backend = "systemd"
scope = "user"
configFiles = [
{ path = ".config/git/ignore" source = "./git/ignore" },
{ path = ".profile" source = "./shell/profile" }
]
}
source is relative to the .pcl file's own directory, so the repo is self-contained: clone it on any machine and deploy. path is relative to your home. Neither carries a username or an OS, which is what makes one repo work everywhere:
Now git diff shows you what changed in your actual dotfiles, not in an escaped string. A source may not climb out of the repo directory, so a stack file can't be turned into a read primitive for the rest of your disk.
One constraint: PCL lists are homogeneous, so a single configFiles list uses source throughout or content throughout; you can't mix the two forms.
Config you compute instead of copy-paste¶
For anything with structure, use dataFiles: you write the data, Punix writes the file.
module Dotfiles {
backend = "systemd"
scope = "user"
dataFiles = ["GitAliases"]
}
module GitAliases {
path = ".config/git/aliases.json"
format = "json" # or "yaml"
data = {
st = "status -sb"
co = "checkout"
lg = "log --oneline --graph --decorate"
}
}
An absolute path still works if you want one (/etc/… in a system-scope stack, or a full path here). Relative simply means "under my home".
Two things you get for free here that a literal string cannot give you:
- It cannot produce broken syntax. A serializer cannot be made to break its own grammar, so no value of yours escapes into structure.
- No type surprises. Every string is quoted, which closes the classic YAML trap where
yes,no,1:30and1.0silently become booleans, sexagesimals and floats.version = "1.0"stays the string you wrote.
And because data is an ordinary PCL value, it can be derived rather than typed out. See composing a stack for folding config out of a shared model.
Run a program in the background, as you¶
scope = "user" also gives you user services: systemctl --user, no root, no sudo.
module Backup {
pname = "backup" version = "1" recipe = "std.shell"
recipeArgs = { command = "mkdir -p $OUTPUT/bin && cp backup.sh $OUTPUT/bin/backup && chmod +x $OUTPUT/bin/backup" }
source = { type = "local" path = "./src" }
}
module BackupSvc {
name = "backup" package = "Backup" binary = "backup"
serviceConfig = { Restart = "always" }
}
module Dotfiles {
backend = "systemd"
scope = "user"
storeModules = ["Backup"]
services = ["BackupSvc"]
}
The unit lands in ~/.config/systemd/user/, and the process runs as you.
Linux only, and the session must exist
User services need systemd, so this part is Linux-only. macOS would need a launchd backend, which does not exist yet. On a server, loginctl enable-linger you keeps your user services running when you are not logged in. On a minimal container image you also need libpam-systemd, or there is no user session bus for systemctl --user to talk to.
What Punix will refuse to write, and why¶
Two paths, and the rule behind them is deny what undo cannot fix:
| refused | because |
|---|---|
~/.ssh/… |
the one mistake rollback can't repair. Clobber authorized_keys on a machine you reach over SSH and you've destroyed the access you'd need to run the rollback |
~/.punix/… |
your store and your generation history. A config file that could rewrite a generation record could rewrite its own provenance |
~/.config/systemd/user/… |
refused to configFiles only: the unit directory belongs to the renderer, so a hand-written unit there would be one the generation attributes to no service. Declare a services entry and Punix writes the unit for you |
Your shell config is not on that list, deliberately. ~/.profile, ~/.bashrc, ~/.zshrc, ~/.config/fish/config.fish are all manageable. An earlier version refused them on the grounds that they "execute as you", which sounds prudent and isn't: the deploy already runs as you, so a file that executes at your next login gives an attacker nothing they didn't have when you typed the command. That rule also couldn't be stated consistently; it blocked ~/.bashrc while permitting ~/.local/bin/ls, which shadows the real one on your PATH.
A bad shell config doesn't need a refusal to protect you. punix service rollback puts the previous bytes back in a second. That is the reason to use Punix for this at all.
Limitations¶
Read this before you move your dotfiles repo over.
Local only. scope = "user" resolves your home directory on the machine running punix, so deploying it to a remote host with --target is refused rather than writing your local path onto someone else's machine. Run it on the machine you want configured.
Punix does not adopt files it did not write. There is no "import my existing dotfiles" step. Move them in yourself, once, and delete the originals. The first deploy overwrites whatever is at the path.
No templating. content is a literal, source is a file copied verbatim, and data is a value; there is no expression language for building strings. For structured formats dataFiles covers it. For a bespoke text format with computed pieces, this is currently a gap.
One file at a time. source names a file, not a directory. A config tree (~/.config/fish/ with its functions/ and conf.d/) means one entry per file today.
How this compares¶
| Punix | GNU Stow | chezmoi | home-manager | |
|---|---|---|---|---|
| declarative | ✅ | ➖ symlink farm | ✅ | ✅ |
| one-second rollback to exact previous bytes | ✅ | ❌ | ❌ | ✅ |
| structured config you compute, not copy | ✅ dataFiles |
❌ | ✅ templates | ✅ |
| refuses to write dangerous paths | ✅ | ❌ | ❌ | ❌ |
| portable across machines out of the box | ✅ | ✅ | ✅ | ✅ |
| needs a whole package manager adopted | ❌ | ❌ | ❌ | ✅ Nix |
| also manages servers with the same file | ✅ | ❌ | ❌ | ➖ via NixOS |
Punix gives you rollback and refusals that the lightweight tools do not, without asking you to adopt Nix, and it is the only one here that manages your laptop and your servers from the same file and the same command.
Where next¶
- Composing a stack: the full surface, including
scope,dataFiles, and services. - Generations and rollback: what is in a generation and why rollback is O(1).
- Your first deploy: the same commands, pointed at a server.