setup-dotFiles.sh - set up a fresh Arch Linux workstation, one question at a time
Author: Mateusz Okulanis. GNU GPLv3 or later. See LICENSE.
This is the arch branch: everything here installs with pacman and yay. The Debian and
Ubuntu versions live on master, bookworm, bullseye, ubuntu2004, ubuntu2204 and
ubuntu2404; there is no apt code path on this branch and nothing here will run on those systems.
A new machine, and the same evening spent again: install the packages, paste the same .bashrc,
generate an SSH key, join the docker group, remember what else there was. This repository is that
evening written down once.
setup-dotFiles.sh walks through thirteen steps in a fixed order and asks a Y/n question before
each one, so a run can be steered a single answer at a time. Every step is idempotent by design:
running it twice leaves the machine exactly as running it once did. The block it adds to
~/.bashrc is delimited and replaced rather than appended; an SSH key that already exists is never
regenerated; a config file that already matches is left alone, and one that differs is backed up
before it is replaced.
Nothing has to be taken on trust, either. --dry-run prints every command the script would run and
executes none of them, and run.sh builds a throwaway Arch container in which the whole thing can
be run for real - pacman, yay, makepkg, ssh-keygen and all - without going anywhere near
the machine you are sitting at.
setup-dotFiles.sh [OPTION]...
run.sh [OPTION]... [-- COMMAND...]
Both take --help.
An Arch Linux family system with pacman and sudo, bash 4.4 or newer, internet access, and a
normal user account. The script refuses to run as root: makepkg will not build yay for root at
all, and as root $HOME is /root, so the dotfiles, the SSH key and the RAM disk shortcut would
all be installed for the wrong user. preflight checks each of these and stops before anything is
changed.
The test environment needs podman or docker instead. Nothing else.
There is no installation. Clone the repository and run the script from it:
git clone -b arch https://github.com/FPGArtktic/setup-DotFiles.git
cd setup-DotFiles
./setup-dotFiles.sh-b arch is not optional. The default branch is the apt one.
The script finds bashrc, fzf.bash, tmux.conf and yay-apps.txt next to itself, not in the
current directory, so ~/src/setup-DotFiles/setup-dotFiles.sh works from anywhere. If you would
rather not clone, those four files and the script are all it needs, and they can be fetched
directly - note raw.githubusercontent.com, not github.com, which serves an HTML page rather
than the file:
mkdir setup-dotFiles && cd setup-dotFiles
base=https://raw.githubusercontent.com/FPGArtktic/setup-DotFiles/arch
for f in setup-dotFiles.sh bashrc fzf.bash tmux.conf yay-apps.txt; do
curl -fsSLO "$base/$f"
done
chmod +x setup-dotFiles.shThe chmod matters: downloaded files are not executable. This gets you the installer and nothing
else - zshrc, bash_profile and the Neovim configuration are not in that list, so clone if you
want them (section 7).
--dry-run print what would be done, change nothing
-v, --verbose show each command and each skipped step
-y, --yes answer yes to every question (implies non-interactive)
--git-name NAME global git user.name to configure
--git-email ADDR global git user.email to configure
-V, --version print the version and exit
-h, --help print this help and exit
-- end of options
Every command that changes the machine goes through one run helper, and --dry-run is checked
there. That is what makes the flag worth having: there is no second path a mutation could take, so
a dry run prints lines like
dry would run: sudo pacman -S --needed -- base-devel git
and touches nothing - not ~/.bashrc, not your git identity, not ~/.ssh. It still reads the
machine, so it reports honestly which steps would be skipped because the work is already done.
--yes means do not ask. It never means do not act. It is passed down to the tools underneath
as well - pacman, yay and makepkg all get --noconfirm - because a run that stops at
somebody else's prompt is not unattended.
Two consequences worth knowing before you use it. The SSH key is generated with an empty
passphrase, since ssh-keygen would otherwise ask for one twice and hang forever; the script
warns when it does this. And without --yes the script refuses to run at all when stdin is not a
terminal, rather than treating end-of-input as consent - so curl ... | bash fails loudly instead
of quietly installing everything.
--dry-run --yes together is a full unattended rehearsal, which is the useful thing to run first.
0 when every step that ran succeeded, 1 when at least one failed or preflight refused to
continue, 130 on ctrl-C, 143 on SIGTERM. A failing step does not abort the run: it is recorded,
the remaining steps still get their turn, and the summary at the end lists what completed, what was
skipped and what failed.
In this order. Each is a question you can answer n to.
1. system update. pacman -Syu, then base-devel and git in a transaction of their own -
they are what makepkg needs later, and a cosmetic extra that no longer exists in the repositories
must not be able to take the toolchain down with it. fastfetch is attempted last and is not
fatal.
2. bashrc. Writes bashrc into ~/.bashrc between # >>> setup-dotFiles: dotfiles >>> and
its closing marker, removing any earlier block of the same name first. The first time it touches
the file it keeps one pristine copy at ~/.bashrc.orig.setup-dotFiles.
3. yay. Skipped when yay is already on $PATH. Otherwise it clones yay-bin from the AUR
into a temporary directory, builds it with makepkg -si, and removes the directory afterwards -
nothing is left in your checkout.
4. packages. Reads yay-apps.txt and installs it as one yay -S --needed transaction. If that
aborts, it retries one package at a time so the run still installs what it can, and names the ones
that did not resolve. This is the long step; see section 10 before you accept it.
5. ssh key. An ed25519 key at ~/.ssh/id_ed25519. An existing key is reported and left
untouched - never overwritten, never prompted about. A new key is followed by the URL for adding it
to GitHub and the public key itself.
6. docker. Only when docker is actually installed. Creates the docker group if needed, adds
you to it, and enables docker.service. It warns first, because membership of that group is
equivalent to passwordless root, and reminds you that the membership applies to new logins only.
7. libvirt. Membership of the libvirt group, libvirtd.socket, and the default guest
network. The group is the whole of the access control: 50-libvirt.rules, shipped by the libvirt
package, grants org.libvirt.unix.manage to that group and to nothing else - no sudo, no
editing libvirtd.conf. The socket is enabled rather than the service, so libvirtd starts on
the first connection instead of at boot; if the modular daemons (virtqemud and friends) are
already active the step says so and leaves them alone. The default network ships inactive with
autostart off, and a guest created before it is up has no network at all, which reads as a guest
fault - so the step turns it on and warns when dnsmasq is missing. It also reports whether
/dev/kvm is there, because without VT-x/AMD-V every guest silently falls back to software
emulation and merely feels broken.
VirtualBox was replaced by this. It needed an out-of-tree module rebuilt by DKMS against every installed kernel - and this list keeps four of them - while KVM is part of the kernel itself.
8. fzf. Installs fzf.bash as ~/.fzf.bash and adds a second managed block to ~/.bashrc
that sources it. fzf itself comes from yay-apps.txt; the step only falls back to cloning
junegunn/fzf into ~/.fzf when the package is not installed, and the upstream installer is run
with --no-update-rc so it cannot append to ~/.bashrc behind the block's back.
9. ram disk. ~/RAMDISC as a symlink to /dev/shm - tmpfs, so it is fast and cleared on
reboot. A link that already points there is left alone; a real file or directory in the way is
refused rather than replaced.
10. tmux. Installs tmux.conf as ~/.tmux.conf, keeping a timestamped backup if the existing
file differs. The config clones tpm and installs its plugins the first time tmux starts.
11. git rules. core.fileMode false, user.name and user.email, globally. See section 14
before you accept this one.
12. tailscale. Installs the tailscale package and enables tailscaled. Installing Tailscale
does not connect the machine; run sudo tailscale up afterwards.
13. post-install. A final yay -Syu (or pacman -Syu when yay is absent) and a trim of the
package cache with paccache -rk2. Orphan removal is deliberately left to you - it is the one
operation here that can uninstall something you wanted.
bashrc, fzf.bash and tmux.conf are what the steps above install. The rest of the
configuration ships with the repository but is not wired into the script yet, and has to be copied
by hand:
cp zshrc ~/.zshrc
cp bash_profile ~/.bash_profile
mkdir -p ~/.config && cp -a nvim ~/.config/What each file is:
bashrc - unlimited, timestamped history shared between terminals; a two-line prompt ending in
the current git branch; aliases that are only defined when the tool behind them is installed (eza,
bat, btop, lazygit, lazydocker, podman-tui); and a fastfetch greeting you can silence
with DOTFILES_NO_FETCH=1. Your own additions belong in ~/.bashrc.local, which is sourced last
and is never touched by a reinstall.
zshrc - the same configuration for zsh: Oh My Zsh, the same history behaviour, the same
aliases, the same fzf options. It uses the mtsh theme when that theme is present in
$ZSH/custom/themes and falls back to robbyrussell when it is not. ~/.zshrc.local is the local
override.
bash_profile - one job: make a login shell - ssh host, a text console login, su - -
read ~/.bashrc, which by definition it otherwise does not. It sources whichever of
~/.bash_login or ~/.profile bash would have read first, so creating it shadows nothing, and it
prints nothing, so it is safe for scp and rsync sessions.
tmux.conf - C-Space as the prefix, mouse on, vi-style copy mode, splits that keep the
current directory, M-arrow pane navigation, and a status line showing the branch of whichever
pane is active. tpm plus sensible/resurrect/continuum are cloned and installed the first time tmux
starts.
fzf.bash - fzf defaults: ripgrep or fd as the file source, bat previews, and the fcd,
fo, fgr, fbr and fkill helpers.
nvim/ - a LazyVim configuration, ported from the podman_base work container so the editor
behaves the same on both. It has its own documentation in nvim/README.md, including the two
headless Lazy! sync runs needed on first install.
run.sh builds an Arch Linux image from Containerfile and drops you into it with this repository
bind-mounted at /app. Inside there is a user called user, password user, with passwordless
sudo, and yay already built - so setup-dotFiles.sh can be run for real, twice in a row if you
want to prove it is idempotent, on a machine that gets thrown away afterwards.
--podman use podman (preferred when both engines are installed)
--docker use docker
--engine NAME the same choice spelled out: --engine podman|docker
--rebuild build with --no-cache --pull, ignoring every cached layer
--no-build do not build, run the image that is already tagged
--build-only build the image and stop
--shell interactive shell in the container (this is the default)
--base IMAGE override the base image passed to the Containerfile
--tag REF image to build and run
--dry-run print the engine commands instead of running them
-h, --help this text
--version version and licence
The engine is autodetected. Podman wins when both are installed, because rootless containers cannot
hurt the host; --docker or --engine docker overrides that. --file is always passed
explicitly, so it does not matter that podman looks for Containerfile and docker for Dockerfile.
Ownership of the mounted checkout is handled per engine, and the difference is real. Under rootful
docker a container uid is that host uid, so your uid is baked into the image at build time with
--build-arg DOT_UID and no run-time flag is needed. Under rootless podman the container lives in
a user namespace, so --userns=keep-id is added to map you onto the image user. Rootless docker
can do neither, and run.sh says so rather than silently handing you a checkout you cannot edit.
The image is tagged setup-dotfiles-test-$USER:latest - the uid is baked in, so two users on one
machine must not share a tag.
Everything after -- is executed in the container instead of a shell, and its exit status
becomes the script's exit status, which is what makes this usable from CI:
./run.sh -- ./setup-dotFiles.sh --dry-run
./run.sh -- bash -c './setup-dotFiles.sh -y && ./setup-dotFiles.sh -y'The second line is the idempotency test: two identical unattended runs, and a non-zero exit if the second one is not a no-op.
There is no systemd in there - PID 1 is bash - so systemctl steps are skipped, not failed;
setup-dotFiles.sh notices the container from the marker file the engine leaves
(/run/.containerenv under podman, /.dockerenv under docker). Kernel modules, /dev/kvm and
anything wanting a real /sys are out of scope by construction.
The mount is read-write, which is deliberate - it is how you inspect the damage afterwards - but it is also why you should not point this at a tree you care about.
Kept only so that older notes and bookmarks keep working. It prints a note and forwards everything
to ./run.sh --docker. Use run.sh.
The first five minutes on a new machine:
git clone -b arch https://github.com/FPGArtktic/setup-DotFiles.git
cd setup-DotFiles
./setup-dotFiles.sh --dry-run --yes # rehearse the whole thing, change nothing
./setup-dotFiles.sh --git-name 'Your Name' --git-email you@example.comTry it in a container before trusting it with your own $HOME:
./run.sh # build, then drop into a shell at /app
./run.sh --rebuild # forget every cached layer first
./run.sh --docker -- ./setup-dotFiles.sh -ySee what it would do, in detail, without doing any of it:
./setup-dotFiles.sh --dry-run --yes -v # -v also prints each package nameReinstall the dotfiles after editing them, and nothing else - answer n to every other question:
./setup-dotFiles.shyay-apps.txt is one package name per line. Blank lines and #-comments are dropped, duplicates
are dropped, and the surviving names are passed as separate arguments - never as an unquoted
$(cat ...) the shell could glob-expand. Annotating the file is free, and the file itself explains
its own rules.
Check a name before you add it:
yay -Si lazygitThat is not pedantry. yay aborts the whole transaction on one unresolvable name, so a single
typo costs you every other package in the file - which is exactly how this list once failed.
The list is well over a hundred packages, in two halves. The first is the maintainer's own machine:
four kernels, the KVM stack, a browser, desktop applications. The second is the set the dotfiles in
this repository actually invoke - eza, bat, fzf, ripgrep, the Neovim toolchain, the
clipboard bridges - and trimming that half is what makes the aliases and the editor quietly stop
working. Trim the first half to taste; read the comments before trimming the second.
tailscale is deliberately absent: it has its own opt-in step, so that the machine is not put on a
private network as a side effect of installing a package list.
In the repository:
setup-dotFiles.sh the installer
run.sh builds and enters the test container, podman or docker
docker_test.sh compatibility shim for `run.sh --docker`
Containerfile the test image; ARG DOT_BASE_IMAGE/DOT_USER/DOT_UID/DOT_GID/DOT_TZ
.containerignore byte-identical to .dockerignore; the build context is empty by design
.dockerignore
yay-apps.txt the package list
bashrc installed as a managed block inside ~/.bashrc
fzf.bash installed as ~/.fzf.bash, sourced from that block
tmux.conf installed as ~/.tmux.conf
zshrc zsh counterpart of bashrc; copy it yourself (section 7)
bash_profile makes a login shell read ~/.bashrc; copy it yourself
nvim/ LazyVim configuration; see nvim/README.md
img/logo.jpeg the logo above
LICENSE GPLv3
What a full run leaves on the machine:
~/.bashrc two delimited blocks: `dotfiles` and `fzf`
~/.bashrc.orig.setup-dotFiles one pristine copy, taken before the first block
~/.bashrc.local yours; sourced last, never touched by a reinstall
~/.fzf.bash from fzf.bash
~/.fzf/ only when fzf was not installed as a package
~/.tmux.conf from tmux.conf
~/.tmux.conf.bak.YYYYmmdd-HHMMSS a backup, whenever an existing file differed
~/.ssh/id_ed25519 and .pub
~/RAMDISC symlink to /dev/shm
~/.gitconfig user.name, user.email, core.fileMode false
ASSUME_YES=1 same as --yes
GIT_AUTHOR global git user.name (same as --git-name)
GIT_EMAIL global git user.email (same as --git-email)
NO_COLOR suppress coloured output; both scripts honour it
TMPDIR where the yay build and the ~/.bashrc rewrite happen
DOTFILES_NO_FETCH=1 silence the fastfetch greeting in the installed bashrc
DOTFILES_IN_CONTAINER=1 set by run.sh inside the container, for anything you run in there
Precedence is flags, then environment, then what is already configured on the machine, then the built-in default.
pacman not found - this branch targets Arch Linux only. You are on a Debian or Ubuntu system.
Use master, bookworm, bullseye, ubuntu2004, ubuntu2204 or ubuntu2404.
do not run this script as root. Run it as yourself. makepkg refuses root outright, and as
root every dotfile would be installed into /root.
stdin is not a terminal - re-run with --yes. Something is feeding the script a pipe or a
closed stdin - a curl ... | bash, cron, a CI job. This is refused on purpose. Pass --yes if you
really do want every step.
A package could not be installed. The name does not resolve. Check it with yay -Si <name> and
fix yay-apps.txt; the bulk transaction already fell back to one package at a time, so everything
else did get installed.
docker is not installed - nothing to configure. The docker step configures docker, it does
not install it. Run the package step first, or install docker yourself, then re-run.
docker: permission denied after the docker step. Group membership only applies to new logins.
newgrp docker, or log out and back in.
An SSH session looks nothing like the terminal on the desktop. A login shell does not read
~/.bashrc. Install bash_profile as ~/.bash_profile (section 7).
Tailscale is installed but the machine is not on the network. Installing it does not connect
it: sudo tailscale up.
neither podman nor docker is installed. sudo pacman -S podman - rootless, with nothing to
enable - or sudo pacman -S docker && sudo systemctl enable --now docker.
Files written in /app are owned by somebody else. Rootless docker cannot map your uid onto
the container user, and run.sh warns about this when it happens. Install podman, or use rootful
docker.
The image build fails on archlinux-keyring or a signature. The base image is stale relative
to the mirrors. ./run.sh --rebuild pulls a fresh base and redoes the keyring refresh.
Known, deliberate, or not worth fixing yet:
- The built-in git identity fallback is the maintainer's name and address. It is only used when
the machine has no global identity at all and you gave neither
--git-name/--git-emailnorGIT_AUTHOR/GIT_EMAIL- an identity that is already configured is never overwritten without the script saying so first - but a fresh machine plus a reflexywill stamp somebody else's name on your commits. Pass the flags. zshrc,bash_profileandnvim/ship with the repository, and their own headers describe themselves as installed bysetup-dotFiles.sh, but no step installs them yet. Section 7 has the three commands that do it by hand.--yesgenerates the SSH key without a passphrase. That is the price of not hanging onssh-keygen's prompt.yay-apps.txtcarries the maintainer's own machine as well as the dotfiles' dependencies: four kernels, the KVM/libvirt stack, a browser. It is meant to be edited.- The container cannot exercise systemd, kernel modules or
/dev/kvm. A green run in there does not prove the docker and libvirt steps work on metal. - There are no automated assertions after a test run. The regression test is running
setup-dotFiles.sh -ytwice and checking that the second run changes nothing. run.shmounts the checkout read-write, so a container run can write into your working tree.docker_test.shexists only for compatibility and will not gain any features.- podman warns that
SHELLis not persisted in OCI images on nearly every build step. It is noise;CMDis set explicitly at the end of theContainerfile.
Written by Mateusz Okulanis. Email: FPGArtktic@outlook.com
This project is licensed under the GNU General Public License v3.0. See LICENSE for the full
text.
Copyright (C) 2025 Mateusz Okulanis
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with this program. If not, see https://www.gnu.org/licenses/.
