dek
Declarative environment setup. One TOML, any machine.
Install
# or
# setup completions
Usage
All commands have short aliases: apply, check, plan, run, state, test, dx (exec).
Config is loaded from: ./dek.toml, ./dek/, or $XDG_CONFIG_HOME/dek/ (fallback).
Config
# Packages
[] # auto-detects: pacman, apt, brew
= ["curl", "git", "htop"]
[]
= ["build-essential"]
[] # falls back to yay for AUR packages
= ["base-devel", "yay"]
[]
= ["bat", "eza", "ripgrep"]
[]
= ["github.com/junegunn/fzf@latest"]
[]
= ["prettier", "typescript"]
[]
= ["httpie", "tldr"]
[]
= ["poetry", "black"]
[]
= ["jq", "yq"]
# Systemd services
[[]]
= "docker"
= "active"
= true
# User services (systemctl --user, no sudo)
[[]]
= "syncthing"
= "active"
= true
= "user"
# Files
[]
= "~/.zshrc"
[]
= "~/.bashrc"
= { = "~/.config/app/config.json", = "1h" }
[]
= "~/.config/nvim"
[]
= ["export PATH=$HOME/.local/bin:$PATH"]
# Structured line management
[[]]
= "/etc/needrestart/needrestart.conf"
= "$nrconf{restart} = 'l';"
= "#$nrconf{restart} = 'i';"
= "replace"
[[]]
= "/etc/ssh/sshd_config"
= "PermitRootLogin no"
= "^#?PermitRootLogin\\s+"
= "replace"
# Shell
[]
= "ls -larth"
= "git"
[]
= "nvim"
# System
= "Europe/Istanbul"
= "workstation"
# Scripts (installed to ~/.local/bin)
[]
= "scripts/cleanup.sh"
# Custom commands
[[]]
= "setup-db"
= "psql -c 'SELECT 1 FROM pg_database WHERE datname=mydb'"
= "createdb mydb"
# Assertions
[[]]
= "dotty up to date"
= "git -C ~/dotty fetch -q && test $(git -C ~/dotty rev-list --count HEAD..@{upstream}) -eq 0"
= "dotty has remote changes"
[[]]
= "note conflicts"
= "rg --files ~/Sync/vault 2>/dev/null | grep conflict | sed 's|.*/||'"
[[]]
= "stow"
= "for p in common nvim tmux; do stow -d ~/dotty -n -v $p 2>&1 | grep -q LINK && echo $p; done"
File Fetch
Download files from URLs. Results are cached at ~/.cache/dek/url/. Use ttl to control cache expiry:
[]
# Cache forever (re-fetches only when cache is cleared)
= "~/.bashrc"
# Cache for 1 hour — re-fetches if older
= { = "~/.config/app/config.json", = "1h" }
Supported TTL units: s (seconds), m (minutes), h (hours), d (days). Can be combined: 1h30m.
Vars
Runtime variables defined in meta.toml, set in the process environment before anything runs. Available to all providers, commands, scripts — locally and remotely.
# meta.toml
[]
= "myapp"
= "/opt/default"
# Scoped by @label — applied when that label is selected
[]
= "/opt/staging"
= "staging-db"
[]
= "/opt/production"
= "prod-db"
# Scoped by config key
[]
= "true"
Base vars are always set. Scoped vars overlay when their selector is active:
Vars are inherited by all child processes, so [[command]] check/apply, [script], and remote dek apply all see them.
Cache Key
Skip steps when a value hasn't changed since last successful apply. Works on [[command]], [[service]], and [[file.line]].
cache_key — a string value (supports $VAR expansion):
[[]]
= "generate test data"
= "test -f /opt/test/input.csv"
= "generate-data.sh"
= "$INPUT_FILE_SIZE_MB" # only re-runs when size changes
cache_key_cmd — a command whose stdout is the cache key:
[[]]
= "deploy jar"
= "test -f /opt/dpi/jar/dpi.jar"
= "cp build/dpi.jar /opt/dpi/jar/"
= "sha256sum build/dpi.jar" # only re-deploys when jar changes
Cache state is stored in ~/.cache/dek/state/. The provider's check always runs — if the state is missing (e.g. file deleted), apply runs regardless of cache. When check passes and the cache key is unchanged, apply is skipped. When the cache key changes (e.g. a $VAR in meta.toml was updated), apply re-runs even if check still passes — this lets you force re-apply by changing a var.
Assertions
Assertions are check-only items — they report issues but don't change anything. Two modes:
check — pass if command exits 0:
[[]]
= "docker running"
= "docker info >/dev/null 2>&1"
= "docker daemon is not running"
= "some regex" # optional: also match stdout
foreach — each stdout line is a finding (zero lines = pass):
[[]]
= "stow packages"
= "for p in common nvim; do stow -n -v $p 2>&1 | grep -q LINK && echo $p; done"
In dek check, assertions show as ✓/✗. In dek apply, failing assertions show as issues (not "changed") and don't block other items.
Conditional Execution
Any item supports run_if — a shell command that gates execution (skip if non-zero):
[]
= ["base-devel"]
= "command -v pacman"
[[]]
= "desktop stow"
= "echo $(uname -n) | grep -qE 'marko|bender'"
= "..."
[]
= "test -d /etc/apt" # skip entire config file
Package:Binary Syntax
When package and binary names differ:
[]
= ["ripgrep:rg", "fd-find:fd", "bottom:btm"]
Installs ripgrep, checks for rg in PATH.
Split Config
dek/
├── meta.toml # project metadata + defaults
├── banner.txt # optional banner (shown on apply/help)
├── inventory.ini # remote hosts (one per line)
├── 00-packages.toml
├── 10-services.toml
├── 20-dotfiles.toml
└── optional/
└── extra.toml # only applied when explicitly selected
Files merged alphabetically. Use dek apply extra to include optional configs.
meta.toml
= "myproject"
= "Project deployment"
= "1.0"
= "0.1.28" # auto-update dek if older
= ["@setup", "@deploy"] # default selectors for apply
= "../devops/inventory.ini" # custom inventory path
= true # symlink dek + config on remote hosts
[]
= "ubuntu:22.04"
= true
= ["./data:/opt/data"] # bind mounts for test container
Labels & Selectors
Tag configs with labels for grouped selection:
# 10-deps.toml
[]
= "Dependencies"
= ["setup"]
[]
= ["curl", "git"]
# 20-deploy.toml
[]
= "Deploy"
= ["deploy"]
[]
= "/opt/app/app.jar"
When defaults is set in meta.toml, a bare dek apply applies only those selectors. Without defaults, it applies all non-optional configs (backward compatible).
Run Commands
Define reusable commands:
[]
= "Deploy the application"
= ["os.rsync"]
= "rsync -av ./dist/ server:/var/www/"
[]
= "Backup database"
= "scripts/backup.sh" # relative to config dir
[]
= "systemctl restart myapp"
= true # prompt before running
[]
= "journalctl -fu myapp"
= true # interactive, uses ssh -t
Remote Run
Run commands on remote hosts without deploying dek — just SSH the command directly:
-t— single host, prints output directly. Withtty: true, usesssh -tfor interactive commands.-r— multi-host from inventory, runs in parallel with progress spinners.tty: truecommands are rejected (can't attach TTY to multiple hosts).confirm: true— prompts[y/N]before running (works both locally and remotely).- Vars — base vars from
meta.toml[vars]are exported to the remote shell automatically, so$VARreferences in remote commands resolve correctly.
Remote
Apply to remote hosts via SSH:
Use -q/--quiet to suppress banners (auto-enabled for multi-host). Use --color always|never|auto to control colored output.
Multi-host with Inventory
Ansible-style inventory.ini (one host per line, [groups] and ;comments ignored):
# inventory.ini
[web]
web-01
web-02
web-03
[db]
db-master
Hosts are deployed in parallel. Override inventory path in meta.toml:
= "../devops/inventory.ini"
Remote Install
With remote_install = true in meta.toml, dek symlinks itself and the config on remote hosts after deploy:
~/.local/bin/dek → /tmp/dek-remote/dek
~/.config/dek → /tmp/dek-remote/config/
This lets you run dek directly on the remote (e.g. dek run, dek list) without re-deploying.
Deploy Workflow
Use [[artifact]] to build locally before shipping to remotes or baking:
[[]]
= "app.jar"
= "mvn package -DskipTests -q"
= ["src", "pom.xml"] # skip build if unchanged
= "target/app-1.0.jar" # build output
= "artifacts/app.jar" # placed in config for shipping
[]
= "/opt/app/app.jar"
[[]]
= "app"
= "active"
# 1. Builds artifact locally (skips if watch hash unchanged)
# 2. Packages config + artifact into tarball
# 3. Ships to all app-* hosts in parallel
# 4. Copies jar, restarts service
# Artifact is built and included in the baked binary
Artifacts are resolved before any config processing — they work with apply, apply -r, and bake.
Freshness can be determined two ways:
watch— list of files/directories to hash (path + size + mtime). Build is skipped when the hash matches the previous run. Best for source trees.check— shell command that exits 0 if the artifact is fresh. Use for custom logic (e.g.,test target/app.jar -nt pom.xml).
deps — local dependencies needed before build. Ensures build tools exist on the machine running the build:
[[]]
= "app.jar"
= "mvn package -DskipTests -q"
= ["apt.default-jdk:java", "apt.maven:mvn"]
= "target/app-1.0.jar"
= "artifacts/app.jar"
Format: "package:binary" — installs package if binary isn't in PATH. Prefix with package manager (apt., pacman., brew.) to force a specific one, or omit for auto-detection (os.).
Auto Update
Set min_version in meta.toml to ensure all users/hosts run a compatible version:
= "0.1.28"
If the running dek is older, it auto-updates via cargo-binstall (preferred) or cargo install, then exits with a prompt to rerun.
Inline
Quick installs without a config file:
Test
Bakes config into the binary and runs it in a container. The baked dek inside the container is fully functional — apply, list, run all work.
Containers are kept by default and named dek-test-{name} (from meta.toml name or directory). On subsequent runs, dek rebakes the binary, copies it into the existing container, reapplies config, and drops into a shell — installed packages and files persist.
Exec
Run commands directly in the test container:
Configure defaults in meta.toml:
[]
= "ubuntu:22.04"
= ["./data:/opt/data", "/host/path:/container/path"]
Mounts are bind-mounted into the test container. Relative host paths are resolved against the config directory.
CLI flags override meta.toml (-i/--image, -r/--rm).
Completions
Dynamic completions for configs, @labels, and run commands.
Manual
Via dek config
Add to any config (e.g., 15-shell.toml):
[[]]
= "dek completions"
= "dek setup"
= "dek _complete check"
Completions support all aliases (a, c, p, r, t, dx) and dynamically complete config keys, @labels, and run command names from whatever config is in the current directory.
State
Query system state via shell commands with optional rewrite rules. Probes run in parallel.
[[]]
= "machine"
= "uname -n"
[[]]
= "screen"
= "hyprctl -j monitors | jq -r '.[].description'"
= [
{ = "Samsung.*0x01000E00", = "tv"},
{ = "C49RG9x", = "ultrawide"},
]
[[]]
= "hour"
= "date +%H"
= [
{ = "^(2[0-3]|0[0-7])$", = "night"},
{ = ".*", = "day"},
]
Rewrite rules are checked in order against raw stdout. First regex match wins, output replaced with value. No match = raw output.
Alias: s. Useful in scripts:
&&
&&
theme=
Bake
Embed config into a standalone binary: