Tour por mis dotfiles: arquitectura modular para setup rápido en Arch
Cada vez que reinstalaba el sistema o levantaba una VM nueva, perdía algunas horas reaplicando configs. Lo resolví con un repo de dotfiles construido alrededor de una idea simple: un comando, un sistema listo — siempre que la base sea Arch (o Manjaro) con recursos equivalentes.
Este post es un tour por dentro: la estructura, las decisiones de diseño, y por qué Arch es la base correcta para este tipo de automatización.
Por qué Arch como base
La elección no es estética. Arch trae tres cosas que hacen que los dotfiles automatizados sean mucho más predecibles:
- Paquetes atomizados en la base oficial. Casi todo lo que uso (
docker,tmux,neovim,zsh,firefox) está enpacmandirectamente, en la versión actual. Sin PPAs, sin repositorios extras, sinapt update && apt upgradeesperando 10 minutos. - AUR cubre el resto. Lo que no está en el oficial (
visual-studio-code-bin,slack-desktop,spotify,postman-bin) está en el AUR yyayresuelve dependencias automáticamente. Esto elimina la necesidad de.deb,.AppImageosnapesparcidos por el sistema. - Sistema limpio de fábrica. Arch arranca sin desktop, sin servicios superfluos, sin snapd ni flatpakd corriendo. Lo que instalo sé que es mío. Esto simplifica los scripts: puedo asumir un estado base mínimo y construir encima.
La comunidad activa cierra el círculo: para cualquier paquete del AUR, generalmente hay gente manteniéndolo, incluso para tools oscuras. Lo que reduce el riesgo de depender de un binario que va a morir en el próximo upgrade.
El árbol del proyecto
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
dotfiles/
├── _setup.sh # bootstrap remoto: clona el repo y dispara install
├── install.sh # orquestador principal
├── helpers.sh # funciones compartidas (echo_*, _update, _install, _symlink)
├── packages.sh # arrays PKG (pacman) y AUR (yay)
├── docker-compose.yaml
├── README.md
├── LICENSE.txt
│
├── albert/ # launcher
├── asdf/ # version manager (erlang, elixir, node, ruby)
├── config/ # fuentes, hosts
├── docker/
├── git/
├── heroku/
├── lscripts/ # scripts utilitarios (reposicionar ventana, reconectar headset)
├── node/
├── ruby/
├── ssh/
├── terminator/
├── tmux/
├── xfce/ # desktop env
├── yay/ # AUR helper
└── zsh/ # oh-my-zsh + p10k + plugins
Cada carpeta en la raíz es un módulo con responsabilidad única. Todas tienen la misma firma: un install.sh que sabe instalarse solo.
El punto de entrada: _setup.sh
El punto de entrada es un one-liner para correr en una máquina nueva:
1
curl -sL https://raw.githubusercontent.com/luciotbc/dotfiles/master/_setup.sh | bash
_setup.sh hace tres cosas:
1
2
3
4
DOTFILES=${DOTFILES:-~/.dotfiles}
REPO=${REPO:-luciotbc/dotfiles}
REMOTE=${REMOTE:-https://github.com/${REPO}.git}
BRANCH=${BRANCH:-master}
Variables con defaults y override por env (DOTFILES=/tmp/df bash _setup.sh funciona). Luego:
- Verifica si git existe; si no, aborta con error con color.
- Hace
git clone --depth=1en~/.dotfiles, con flags para no mezclar EOLs (core.eol=lf,core.autocrlf=false) — importante porque los scripts shell se rompen con CRLF. - Entra al directorio clonado y corre
./install.sh.
Detalle pequeño pero útil: no permite reinstalar sobre una instalación existente — si ~/.dotfiles ya existe, aborta en vez de intentar mergear.
El orquestador: install.sh + helpers.sh
El install.sh de la raíz tiene 20 líneas y es deliberadamente simple:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#!/bin/bash
. packages.sh
. helpers.sh
echo_info "Updating packages..."
_update
echo_info "Installing core packages..."
_install core
echo_info "Configure settings..."
_symlink
echo_info "Installing aur packages..."
_install aur
La inteligencia está en helpers.sh. Las tres funciones principales:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
function _update() {
sudo pacman-mirrors --geoip
sudo pacman -Syyu --needed --noconfirm
}
function _install() {
if [[ $1 == "core" ]]; then
for pkg in "${PKG[@]}"; do
sudo pacman -Sy "$pkg" --needed --noconfirm
echo_done "${pkg} installed!"
done
elif [[ $1 == "aur" ]]; then
for aur in "${AUR[@]}"; do
yay -S "$aur" --needed --noconfirm
done
fi
}
function _symlink() {
dirs=$(find . -maxdepth 1 -mindepth 1 -type d -not -name '.git')
for dir in $dirs; do
cd "$dir" || exit
./install.sh
cd ..
done
}
_update usa pacman-mirrors --geoip para elegir el mirror más rápido para la geolocalización actual — en la práctica, hace una diferencia enorme cuando levantás una máquina en otro país.
_install es polimórfica: pasá core para correr sobre ${PKG[@]} con pacman, o aur para correr sobre ${AUR[@]} con yay. Los flags --needed --noconfirm garantizan idempotencia (paquete ya instalado se ignora, sin prompt).
_symlink es el corazón de la modularidad: itera por cada carpeta en la raíz y corre el install.sh que esté ahí. Agregar un módulo nuevo es solo crear una carpeta con install.sh — el orquestador lo descubre solo.
La lista declarativa: packages.sh
En vez de esparcir pacman -S por los scripts, todo lo que viene del oficial se convierte en una lista Bash:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
export PKG=(
albert
base-devel
blueman
chromium
cmake
docker
docker-compose
firefox
fzf
htop
neovim
noto-fonts
openssh
postgresql-libs
terminator
tmux
zsh
# ... ~80 paquetes
)
export AUR=(
brave-bin
ctop
espanso
git-delta
google-chrome
insync
postman-bin
slack-desktop
spotify
visual-studio-code-bin
zeal
)
Ventajas de esta separación:
- Comentar = desinstalar del template. No necesito decidir entre Brave y Chrome para siempre — comento la línea en una máquina específica.
- Diff limpio entre máquinas. Si hago fork para desktop y para notebook, el diff queda concentrado en
packages.sh, sin cambiar shell scripts. - Fuente única de verdad. Todo lo que entra al sistema via gestor de paquetes está en un solo lugar.
El patrón de los módulos
Cada <modulo>/install.sh sigue un esqueleto mínimo. Ejemplo de git/install.sh:
1
2
3
4
5
6
7
8
9
#!/bin/bash
# shellcheck source=helpers.sh
. ../helpers.sh
echo_info "Symlink ~/.gitconfig"
ln -sfT "$HOME/.dotfiles/git/gitconfig" "$HOME/.gitconfig"
echo_done "Git configuration!"
La estrategia es siempre la misma: el archivo de config vive versionado dentro del repo (git/gitconfig), y el install solo crea un symlink en $HOME apuntando a él. Eso significa que:
- Un
git pullen~/.dotfilesactualiza la config en todas las máquinas. - Editar
~/.gitconfigen realidad edita el archivo del repo, entonces los cambios locales quedan rastreables en git. - Para “desinstalar” un módulo, basta con borrar el symlink — el archivo original se queda en el repo.
Los módulos que necesitan más que un symlink hacen más — pero mantienen el mismo punto de entrada install.sh.
zsh/install.sh — clone de plugins + symlink
1
2
3
4
5
6
7
8
9
10
11
12
13
sh -c "$(curl -fsSL .../oh-my-zsh/.../install.sh)" -s --batch
git clone --depth=1 https://github.com/zsh-users/zsh-autosuggestions \
"$HOME/.oh-my-zsh/custom/plugins/zsh-autosuggestions"
git clone --depth=1 https://github.com/zsh-users/zsh-syntax-highlighting.git \
"$HOME/.oh-my-zsh/custom/plugins/zsh-syntax-highlighting"
git clone --depth=1 https://github.com/romkatv/powerlevel10k.git \
"$HOME/.oh-my-zsh/themes/powerlevel10k"
ln -sfT "$HOME/.dotfiles/zsh/.p10k.zsh" "$HOME/.p10k.zsh"
ln -sfT "$HOME/.dotfiles/zsh/zshrc" "$HOME/.zshrc"
chsh -s $(which zsh)
sudo chsh -s $(which zsh)
Instala oh-my-zsh en modo batch (sin prompt interactivo), clona los tres plugins/themes que uso (autosuggestions, syntax-highlighting, powerlevel10k), hace symlink de mis .zshrc y .p10k.zsh, y cambia el shell por defecto a zsh — tanto para el usuario como para root.
asdf/install.sh — version manager + lenguajes
1
2
3
4
5
6
7
8
9
10
11
12
13
14
git clone https://github.com/asdf-vm/asdf.git "$HOME/.asdf"
cd "$HOME/.asdf" && git checkout "$(git describe --abbrev=0 --tags)"
ln -sfT "$HOME/.dotfiles/asdf/asdfrc" "$HOME/.asdfrc"
ln -sfT "$HOME/.dotfiles/asdf/tool-versions" "$HOME/.tool-versions"
~/.asdf/bin/asdf plugin-add erlang
~/.asdf/bin/asdf plugin-add elixir
~/.asdf/bin/asdf plugin-add nodejs
~/.asdf/bin/asdf plugin-add ruby
~/.asdf/bin/asdf install ruby 2.7.1
~/.asdf/bin/asdf global ruby 2.7.1
# ... lo mismo para erlang, elixir, node
asdf se clona en ~/.asdf y hace checkout en el último tag estable. Los plugins de lenguaje se convierten en instalación declarativa via .tool-versions versionado.
yay/install.sh — bootstrap del AUR
1
2
3
4
5
git clone https://aur.archlinux.org/yay.git
cd yay
makepkg -si
cd ..
rm -rf yay
Este es especial: necesita correr antes de _install aur, porque yay es el helper que va a instalar todo del AUR después. Por eso aparece entre _install core y _install aur en el flujo principal — es la parte “manual” del bootstrap.
El flujo completo, del curl a la máquina lista
Secuencia de lo que pasa cuando corrés el one-liner en un Arch limpio:
_setup.shse baja via curl, validagit, clona el repo en~/.dotfiles.- Empieza el
install.shde la raíz. _updateajusta el mirror viageoipy correpacman -Syyu._install corerecorre${PKG[@]}instalando todo del oficial._symlinkitera por las carpetas y dispara cadainstall.sh(incluido el deyay).- Cada módulo se ocupa de su propio dotfile: zsh cambia el shell, asdf compila Ruby/Elixir, git linkea
.gitconfig, etc. - Por último,
_install aurrecorre${AUR[@]}viayay.
Al final, al loguearte, p10k ya está bonito, ruby -v retorna 2.7.1, docker --version responde, y VS Code está en el menú.
Reaplicando en una máquina remota
El caso para el que el proyecto fue pensado: VPS Arch nuevo, o Manjaro en un notebook reinstalado. El setup mínimo de mi lado es:
1
2
3
4
5
6
7
8
9
10
11
# 1. Como root, crear usuario y dar sudo
useradd -m -G wheel -s /bin/bash lucio
passwd lucio
visudo # descomentar %wheel
# 2. Loguearse como el usuario y correr el one-liner
curl -sL https://raw.githubusercontent.com/luciotbc/dotfiles/master/_setup.sh | bash
# 3. Configurar SSH/GPG (instrucciones en el README)
ssh-keygen -t rsa -b 4096 -C "hi@lucio.app"
gpg --default-new-key-algo rsa4096 --gen-key
La premisa es que la máquina tiene recursos equivalentes — memoria para compilar Erlang/Ruby via asdf, espacio para el conjunto de paquetes (unos 6–8 GB con todo) y buena red para el initial pull. Si es una máquina más chica, se pueden comentar paquetes en packages.sh o módulos pesados antes de correr.
Lo que cambiaría hoy
Mirando con calma, tres cosas que mejoraría:
- Idempotencia más fuerte. Hoy los
install.shde los módulos asumen estado limpio. Correrlo dos veces puede fallar en losgit clone(asdf, oh-my-zsh) porque la carpeta ya existe. Un check del tipo[ -d ~/.asdf ] || git clone ...lo resuelve. - Versiones de lenguajes fijadas en archivo separado. Hoy las versiones (Ruby 2.7.1, Node 10.16.0) están hardcodeadas en
asdf/install.sh. Moverlas a.tool-versionsy usar soloasdf installsin argumentos sería más limpio. - Tests en container. El
.dockerdev/Dockerfileque existe en el repo da la base para correr el setup completo en un container Arch y validar que termina sin error — bueno para CI.
Resumen de lo que creo que el proyecto acierta
Lo que más me sirve después de casi un año usando este layout:
- Modularidad por carpeta elimina la necesidad de un framework de dotfiles (Dotbot, GNU Stow, chezmoi). Cada módulo es independiente, se lee como código de configuración, no como configuración declarativa abstracta.
packages.shseparado hace que “qué está instalado” sea auditable en un solo archivo.- Symlink-first garantiza que mi config vive en git, no en un backup esporádico.
- Bootstrap remoto convierte la máquina nueva en un problema de 10 minutos, no de una tarde.
Y principalmente: aprovechar Arch + AUR como repositorio universal de software resuelve lo que en otras distros se convierte en una solución de 4 herramientas (apt + snap + flatpak + AppImage). Eso reduce drásticamente lo que necesito codear en los dotfiles — casi todo es “agregar el nombre del paquete al array”.
Repo público en github.com/luciotbc/dotfiles si querés hacer fork.