<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Vimukthi Shohan Jayawardana</title>
    <description>The latest articles on DEV Community by Vimukthi Shohan Jayawardana (@vimukthishohanjay).</description>
    <link>https://dev.to/vimukthishohanjay</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F876842%2F6a816f4d-9854-4e6a-b6b5-2c9170a28328.jpeg</url>
      <title>DEV Community: Vimukthi Shohan Jayawardana</title>
      <link>https://dev.to/vimukthishohanjay</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/vimukthishohanjay"/>
    <language>en</language>
    <item>
      <title>How to Manage Dotfiles with GNU Stow (and Rebuild Your Whole Ubuntu Server in One Command)</title>
      <dc:creator>Vimukthi Shohan Jayawardana</dc:creator>
      <pubDate>Sat, 11 Jul 2026 19:52:54 +0000</pubDate>
      <link>https://dev.to/vimukthishohanjay/how-to-manage-dotfiles-with-gnu-stow-and-rebuild-your-whole-ubuntu-server-in-one-command-42ia</link>
      <guid>https://dev.to/vimukthishohanjay/how-to-manage-dotfiles-with-gnu-stow-and-rebuild-your-whole-ubuntu-server-in-one-command-42ia</guid>
      <description>&lt;p&gt;Every time I got a fresh Ubuntu VPS, I lost half a day to the same ritual: install zsh, copy over my &lt;code&gt;.zshrc&lt;/code&gt;, remember which tools I had on the old machine, fix the &lt;code&gt;bat&lt;/code&gt; vs &lt;code&gt;batcat&lt;/code&gt; mess, reconfigure tmux, and inevitably discover a missing CLI a week later mid-task.&lt;/p&gt;

&lt;p&gt;The fix was two things working together: &lt;strong&gt;GNU Stow&lt;/strong&gt; for the config files, and a small set of &lt;strong&gt;idempotent bash scripts&lt;/strong&gt; for everything Stow can't do (packages, services, login shell). Now a new server goes from bare Ubuntu 24.04 to my complete dev environment with one script, and I can re-run it any time without breaking anything.&lt;/p&gt;

&lt;p&gt;This post walks through the whole setup as released in &lt;strong&gt;v1.0.0&lt;/strong&gt;, then where it's heading next: from a collection of scripts to a proper CLI-driven tool. The full repo is on GitHub: &lt;a href="https://github.com/VimukthiShohan/ubuntu-server-dotfiles" rel="noopener noreferrer"&gt;ubuntu-server-dotfiles&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is GNU Stow, and why use it for dotfiles?
&lt;/h2&gt;

&lt;p&gt;GNU Stow is a symlink farm manager. You keep your real config files in a git repo, organized into "packages" that mirror your home directory, and Stow creates symlinks from &lt;code&gt;$HOME&lt;/code&gt; into the repo:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;dotfiles/home/
├── zsh/
│   ├── .zshenv                →  ~/.zshenv
│   └── .config/zsh/
│       ├── 00-env.zsh         →  ~/.config/zsh/00-env.zsh
│       └── 30-aliases.zsh     →  ~/.config/zsh/30-aliases.zsh
├── git/
│   ├── .gitconfig             →  ~/.gitconfig
│   └── .config/git/ignore     →  ~/.config/git/ignore
├── tmux/
│   └── .tmux.conf             →  ~/.tmux.conf
└── nvim/
    └── .config/nvim/          →  ~/.config/nvim/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One command links a package into place:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;stow &lt;span class="nt"&gt;-d&lt;/span&gt; home &lt;span class="nt"&gt;-t&lt;/span&gt; ~ zsh git tmux nvim
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why Stow instead of copying files or a bare git repo?&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Edits are live.&lt;/strong&gt; The file in &lt;code&gt;~/.config/nvim/&lt;/code&gt; &lt;em&gt;is&lt;/em&gt; the file in the repo. Change it, test it, commit it — no sync step.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per-tool packages.&lt;/strong&gt; Each tool gets its own directory, so you can stow &lt;code&gt;nvim&lt;/code&gt; on one machine and skip it on another.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Uninstall is clean.&lt;/strong&gt; &lt;code&gt;stow -D nvim&lt;/code&gt; removes exactly the symlinks it created, nothing else.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's already in apt.&lt;/strong&gt; &lt;code&gt;sudo apt install stow&lt;/code&gt; — no bootstrap chicken-and-egg like some dotfile managers.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A &lt;code&gt;.stowrc&lt;/code&gt; file in the repo root means you never have to remember the flags:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;--&lt;span class="n"&gt;dir&lt;/span&gt;=&lt;span class="n"&gt;home&lt;/span&gt;
--&lt;span class="n"&gt;target&lt;/span&gt;=~
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The part Stow can't do: packages, tools, and services
&lt;/h2&gt;

&lt;p&gt;Symlinked configs are useless if &lt;code&gt;zsh&lt;/code&gt;, &lt;code&gt;tmux&lt;/code&gt;, and &lt;code&gt;nvim&lt;/code&gt; aren't installed. This is where most dotfiles repos stop and mine keeps going: &lt;strong&gt;everything installable lives in a plain-text manifest&lt;/strong&gt;, one item per line, &lt;code&gt;#&lt;/code&gt; for comments.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;setup/
├── apt-packages.txt      # apt CLI + service packages
├── install-tools.sh      # Neovim tarball, fnm/node, bun, pnpm, rust, uv
└── tools/
    ├── npm.txt           # npm install -g
    ├── bun.txt           # bun add -g
    ├── cargo.txt         # cargo install --locked
    ├── go.txt            # go install
    └── installers.sh     # guarded curl installers (aws-cli, etc.)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rule that makes this work: &lt;strong&gt;if a tool isn't in a manifest, it doesn't exist.&lt;/strong&gt; Never hand-install something on a server and call it done — add it to the manifest, re-run the apply script, commit. The manifest is the machine.&lt;/p&gt;

&lt;p&gt;Reading a manifest in bash is three lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;read_manifest&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;local &lt;/span&gt;&lt;span class="nv"&gt;file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$file&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;return &lt;/span&gt;0
  &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-vE&lt;/span&gt; &lt;span class="s1"&gt;'^[[:space:]]*(#|$)'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$file&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  An idempotent apply script
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;apply.sh&lt;/code&gt; converges the machine to the declared state. The key word is &lt;em&gt;converges&lt;/em&gt; — it's safe to run on a fresh server or a fully configured one, because every step checks before it acts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Clone only if missing&lt;/span&gt;
&lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/.tmux/plugins/tpm"&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  git clone https://github.com/tmux-plugins/tpm &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/.tmux/plugins/tpm"&lt;/span&gt;

&lt;span class="c"&gt;# Change login shell only if it isn't already zsh&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;SHELL&lt;/span&gt;&lt;span class="k"&gt;:-}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$zsh_path&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"  -&amp;gt; &lt;/span&gt;&lt;span class="nv"&gt;$USER&lt;/span&gt;&lt;span class="s2"&gt; already uses &lt;/span&gt;&lt;span class="nv"&gt;$zsh_path&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;0
&lt;span class="k"&gt;fi
&lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;chsh &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$zsh_path&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$USER&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="c"&gt;# Add to docker group only if not already a member&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="nt"&gt;-nG&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$USER&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-qw&lt;/span&gt; docker&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;usermod &lt;span class="nt"&gt;-aG&lt;/span&gt; docker &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$USER&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It also fixes a classic Ubuntu papercut automatically. On Ubuntu, &lt;code&gt;bat&lt;/code&gt; installs as &lt;code&gt;batcat&lt;/code&gt; and &lt;code&gt;fd&lt;/code&gt; as &lt;code&gt;fdfind&lt;/code&gt; (name collisions with older packages), which breaks every alias and script that expects the real names. The apply script shims them once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nb"&gt;command&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; bat &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null 2&amp;gt;&amp;amp;1 &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;command&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; batcat &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null 2&amp;gt;&amp;amp;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;ln&lt;/span&gt; &lt;span class="nt"&gt;-sfn&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;command&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; batcat&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/.local/bin/bat"&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The whole pipeline is: apt manifest → command shims → language toolchains → docker service → zsh as login shell → clone tmux/prompt plugins → &lt;code&gt;stow&lt;/code&gt; everything → reload tmux config. Run it after every manifest or dotfile change.&lt;/p&gt;

&lt;h2&gt;
  
  
  Handling stow conflicts on an existing machine
&lt;/h2&gt;

&lt;p&gt;The first run on a machine that already has a &lt;code&gt;.zshrc&lt;/code&gt; will fail with a stow conflict — Stow refuses to overwrite real files. That's what &lt;code&gt;--adopt&lt;/code&gt; is for:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;stow &lt;span class="nt"&gt;-d&lt;/span&gt; home &lt;span class="nt"&gt;-t&lt;/span&gt; ~ &lt;span class="nt"&gt;--adopt&lt;/span&gt; zsh git tmux
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--adopt&lt;/code&gt; moves the &lt;em&gt;existing&lt;/em&gt; file into your repo and replaces it with a symlink. Then &lt;code&gt;git diff&lt;/code&gt; shows you exactly how the machine's config differed from your repo, and you decide: keep the machine's version (commit it) or restore yours (&lt;code&gt;git restore .&lt;/code&gt;). My &lt;code&gt;apply.sh --fresh&lt;/code&gt; flag enables this for first-time setup only — you don't want adoption silently pulling drift into your repo on routine runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  A doctor script that never mutates
&lt;/h2&gt;

&lt;p&gt;The third piece is &lt;code&gt;doctor.sh&lt;/code&gt;: a &lt;strong&gt;strictly read-only&lt;/strong&gt; drift check. It verifies the platform is Ubuntu, every apt package in the manifest is installed, required commands exist, plugin repos are cloned, and — the neat trick — runs Stow in dry-run mode to detect symlink conflicts without touching anything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;stow &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DOTFILES&lt;/span&gt;&lt;span class="s2"&gt;/home"&lt;/span&gt; &lt;span class="nt"&gt;-t&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--no&lt;/span&gt; &lt;span class="nt"&gt;--verbose&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;packages&lt;/span&gt;&lt;span class="p"&gt;[@]&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;&amp;amp;1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It exits 1 if anything drifted, so you can even run it from cron or CI. The separation matters: &lt;code&gt;doctor.sh&lt;/code&gt; diagnoses, &lt;code&gt;apply.sh&lt;/code&gt; heals, and neither surprises you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keeping secrets out of the repo
&lt;/h2&gt;

&lt;p&gt;Dotfiles repos are public. Machine identity is not. Two untracked files handle everything machine-specific:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Git identity — the stowed .gitconfig includes this file&lt;/span&gt;
git config &lt;span class="nt"&gt;--file&lt;/span&gt; ~/.gitconfig.local user.name &lt;span class="s2"&gt;"Your Name"&lt;/span&gt;
git config &lt;span class="nt"&gt;--file&lt;/span&gt; ~/.gitconfig.local user.email &lt;span class="s2"&gt;"you@example.com"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# ~/.config/zsh/local.zsh — sourced last by the stowed zsh config&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;SOME_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The repo never contains SSH keys, GitHub auth state (&lt;code&gt;gh&lt;/code&gt;'s &lt;code&gt;hosts.yml&lt;/code&gt;), or tokens — and a static test (&lt;code&gt;tests/ubuntu-config.bash&lt;/code&gt;) fails the build if a forbidden pattern ever sneaks into a tracked file.&lt;/p&gt;

&lt;h2&gt;
  
  
  Putting it together: fresh server in one command
&lt;/h2&gt;

&lt;p&gt;On a brand-new Ubuntu 24.04 server (as of v1.0.0):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/VimukthiShohan/ubuntu-server-dotfiles.git ~/.dotfiles
&lt;span class="nb"&gt;cd&lt;/span&gt; ~/.dotfiles
./setup.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;setup.sh&lt;/code&gt; installs the prerequisites via apt and calls &lt;code&gt;apply.sh --fresh&lt;/code&gt;. From then on, the routine on any machine is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; ~/.dotfiles &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; git pull &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; ./doctor.sh &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; ./apply.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What's next (v2.0.0): from script collection to CLI
&lt;/h2&gt;

&lt;p&gt;That routine still means remembering three script names and where the repo lives. The next release turns the repo into a CLI-driven tool while keeping every script above exactly as it is. Two additions, both plain bash:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A true one-liner for fresh servers.&lt;/strong&gt; No clone-then-cd dance — a &lt;code&gt;bootstrap.sh&lt;/code&gt; at the repo root becomes the curl target:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://raw.githubusercontent.com/VimukthiShohan/ubuntu-server-dotfiles/main/bootstrap.sh | bash
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It gates on Ubuntu, installs git if needed, clones the repo over HTTPS to &lt;code&gt;~/.dotfiles&lt;/code&gt;, and runs &lt;code&gt;setup.sh&lt;/code&gt;. Safe to re-run, and it refuses to touch a &lt;code&gt;~/.dotfiles&lt;/code&gt; directory that isn't actually this repo.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A &lt;code&gt;dotf&lt;/code&gt; command for daily use.&lt;/strong&gt; Installed by Stow itself (it's just another package, &lt;code&gt;home/dotf/&lt;/code&gt;, linking into &lt;code&gt;~/.local/bin&lt;/code&gt;), so the tool that manages the dotfiles is managed like a dotfile:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;command&lt;/th&gt;
&lt;th&gt;does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;dotf apply [--fresh]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;converge the machine (&lt;code&gt;apply.sh&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;dotf doctor&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;read-only drift check (&lt;code&gt;doctor.sh&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;dotf update&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;git pull --ff-only&lt;/code&gt;, then converge&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;dotf test&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the repo's static guard + syntax checks&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The design rule making this safe is what I'd call a &lt;strong&gt;veneer&lt;/strong&gt;: &lt;code&gt;dotf&lt;/code&gt; only delegates to the existing scripts and is forbidden — by the repo's own static tests — from containing any &lt;code&gt;apt-get&lt;/code&gt;, &lt;code&gt;stow&lt;/code&gt;, or &lt;code&gt;sudo&lt;/code&gt; of its own. The scripts stay the single source of converge logic; the CLI is ergonomics. Even finding the repo needs zero config: &lt;code&gt;dotf&lt;/code&gt; resolves its own symlink back through Stow to discover where the clone lives.&lt;/p&gt;

&lt;p&gt;The daily routine collapses to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotf update   &lt;span class="c"&gt;# pull + converge&lt;/span&gt;
dotf doctor   &lt;span class="c"&gt;# am I drifted?&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;GNU Stow vs a bare git repo for dotfiles?&lt;/strong&gt;&lt;br&gt;
A bare repo tracks files in place but gives you no per-tool grouping and no clean uninstall. Stow's package model means each tool is opt-in per machine, and &lt;code&gt;stow -D&lt;/code&gt; cleanly removes one tool's links. Stow also can't accidentally clobber files — conflicts are errors unless you explicitly &lt;code&gt;--adopt&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GNU Stow vs chezmoi/dotbot?&lt;/strong&gt;&lt;br&gt;
Chezmoi is more powerful (templating, secrets integration) but it's another tool with its own DSL to learn and bootstrap. Stow is one apt package, zero config, and plain symlinks you can inspect with &lt;code&gt;ls -la&lt;/code&gt;. For a server environment where the "templating" is just two untracked local files, Stow is enough.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why fnm instead of nvm?&lt;/strong&gt;&lt;br&gt;
fnm is a single fast binary with proper shell integration and no 3,000-line shell function slowing every prompt. The repo's static guard actually rejects any &lt;code&gt;nvm&lt;/code&gt; reference.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why a bash CLI instead of an npm package or a Go/Rust binary?&lt;/strong&gt;&lt;br&gt;
Chicken-and-egg: a fresh server has no Node, no cargo, no prebuilt binary — but it always has bash. A stowed bash script needs zero new toolchain, and the bootstrap one-liner only assumes &lt;code&gt;curl&lt;/code&gt;, which ships in Ubuntu's cloud images.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does this work on macOS?&lt;/strong&gt;&lt;br&gt;
Stow itself does, but this repo is intentionally Ubuntu-server-only — no brew, no GUI apps, no desktop settings. Keeping the scope narrow is what keeps &lt;code&gt;apply.sh&lt;/code&gt; reliable.&lt;/p&gt;




&lt;p&gt;The complete setup — scripts, manifests, and all stow packages — is at &lt;a href="https://github.com/VimukthiShohan/ubuntu-server-dotfiles" rel="noopener noreferrer"&gt;github.com/VimukthiShohan/ubuntu-server-dotfiles&lt;/a&gt;. v1.0.0 is the script collection described above; v2.0.0 adds the bootstrap one-liner and the &lt;code&gt;dotf&lt;/code&gt; CLI. Clone it, gut my configs, keep the skeleton.&lt;/p&gt;

</description>
      <category>linux</category>
      <category>ubuntu</category>
      <category>bash</category>
      <category>productivity</category>
    </item>
    <item>
      <title>How to clone a Keycloak realm on the same instance (fixing "duplicate key value violates unique constraint")</title>
      <dc:creator>Vimukthi Shohan Jayawardana</dc:creator>
      <pubDate>Sat, 11 Jul 2026 15:48:07 +0000</pubDate>
      <link>https://dev.to/vimukthishohanjay/how-to-clone-a-keycloak-realm-on-the-same-instance-fixing-duplicate-key-value-violates-unique-25ja</link>
      <guid>https://dev.to/vimukthishohanjay/how-to-clone-a-keycloak-realm-on-the-same-instance-fixing-duplicate-key-value-violates-unique-25ja</guid>
      <description>&lt;p&gt;If you've ever tried to duplicate a Keycloak realm on the same server — say, to spin up a &lt;code&gt;myrealm-dev&lt;/code&gt; realm alongside your existing &lt;code&gt;myrealm&lt;/code&gt; — you've probably hit this wall:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Export the realm from the Admin Console (&lt;strong&gt;Realm settings → Action → Partial export&lt;/strong&gt;, with clients and groups/roles included).&lt;/li&gt;
&lt;li&gt;Rename it in a text editor, or in the import dialog's "realm name" field.&lt;/li&gt;
&lt;li&gt;Import it back into the same Keycloak instance.&lt;/li&gt;
&lt;li&gt;Watch it fail with:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ERROR: duplicate key value violates unique constraint "constraint_a"
Detail: Key (id)=(51e1a26d-c24f-4454-9a34-708f1fc14917) already exists.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why this happens
&lt;/h2&gt;

&lt;p&gt;A realm export isn't just configuration — it's a snapshot of database rows. Every role, client, user, protocol mapper, component, and authentication flow in the export carries the same internal UUID it has in the live database. Renaming the &lt;code&gt;realm&lt;/code&gt; field changes what the realm is &lt;em&gt;called&lt;/em&gt;, but it does nothing to the dozens (often hundreds) of UUIDs referenced throughout the file. Import that JSON into the instance it came from, and Keycloak tries to insert rows whose primary keys already exist. Every single one collides.&lt;/p&gt;

&lt;p&gt;This is a known limitation, tracked upstream as &lt;a href="https://github.com/keycloak/keycloak/issues/24770" rel="noopener noreferrer"&gt;keycloak/keycloak#24770&lt;/a&gt;. Keycloak's exporter was never designed to produce an import-anywhere-including-here artifact — it assumes you're moving the realm to a different instance (dev → staging → prod), where the UUID space is&lt;br&gt;
independent.&lt;/p&gt;
&lt;h2&gt;
  
  
  The manual fix (and why it doesn't scale)
&lt;/h2&gt;

&lt;p&gt;In principle you can fix this by hand: open the export JSON, find every UUID, and replace it with a fresh one, while keeping track of which old UUID maps to which new UUID so that references between objects (a role's &lt;code&gt;containerId&lt;/code&gt;, a client's &lt;code&gt;serviceAccountClientId&lt;/code&gt;, a flow's execution list) still point at the right thing after the rewrite. For a small realm with a handful of clients this is tedious but doable in an editor with careful find-and-replace. For a realm with custom roles, several clients, an identity provider, and a full set of authentication flows, it's easily 100+ UUIDs, and one missed reference silently breaks a login flow instead of failing loudly at import time. That risk — a broken relationship you don't notice until a user can't log in — is the real reason this needs tooling instead of a text editor.&lt;/p&gt;
&lt;h2&gt;
  
  
  The tool: keycloak-realm-clone
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/VimukthiShohan/keycloak-realm-clone" rel="noopener noreferrer"&gt;&lt;code&gt;keycloak-realm-clone&lt;/code&gt;&lt;/a&gt; is a dependency-free Node.js CLI (and library) that automates exactly this rewrite. It ships on npm, so there's nothing to install ahead of time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx keycloak-realm-clone realm-export.json myrealm-dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This reads &lt;code&gt;realm-export.json&lt;/code&gt;, produces &lt;code&gt;myrealm-dev-realm-export.json&lt;/code&gt;, and prints a summary of what changed. Options:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;Usage: keycloak-realm-clone &amp;lt;export.json&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&amp;lt;new-realm-name&amp;gt; &lt;span class="o"&gt;[&lt;/span&gt;options]
&lt;span class="go"&gt;
Options:
&lt;/span&gt;&lt;span class="gp"&gt;  -o, --output &amp;lt;file&amp;gt;&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;Output path &lt;span class="o"&gt;(&lt;/span&gt;default: &amp;lt;new-realm-name&amp;gt;-realm-export.json&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="gp"&gt;  --old-name &amp;lt;name&amp;gt;&lt;/span&gt;&lt;span class="w"&gt;     &lt;/span&gt;Override auto-detected &lt;span class="nb"&gt;source &lt;/span&gt;realm name
&lt;span class="go"&gt;  --dry-run             Print change summary, write nothing
  -h, --help            Show this help
  -v, --version         Show version
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  What it rewrites
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Every UUID-shaped value in the export — role IDs, client IDs, user IDs, service account references, protocol mapper IDs, component IDs, authentication flow IDs — remapped through a single old→new table, so the &lt;em&gt;same&lt;/em&gt; old UUID always becomes the &lt;em&gt;same&lt;/em&gt; new UUID. That's what keeps a role's container reference, a client's service account link, and a flow's execution chain intact after the rewrite.&lt;/li&gt;
&lt;li&gt;The realm name itself: &lt;code&gt;realm&lt;/code&gt;, &lt;code&gt;displayName&lt;/code&gt;, &lt;code&gt;displayNameHtml&lt;/code&gt;, &lt;code&gt;default-roles-&amp;lt;realm&amp;gt;&lt;/code&gt; role
names, and every client's &lt;code&gt;redirectUris&lt;/code&gt;/&lt;code&gt;baseUrl&lt;/code&gt;/&lt;code&gt;adminUrl&lt;/code&gt; (&lt;code&gt;/realms/&amp;lt;old&amp;gt;/&lt;/code&gt; → &lt;code&gt;/realms/&amp;lt;new&amp;gt;/&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Masked secrets. Keycloak exports secrets as the literal string &lt;code&gt;**********&lt;/code&gt; rather than the real value — client secrets, identity provider &lt;code&gt;clientSecret&lt;/code&gt;, LDAP &lt;code&gt;bindCredential&lt;/code&gt;. The tool deletes these fields outright rather than leaving the placeholder, which is what makes Keycloak generate fresh values on import instead of trying (and failing) to import the literal string &lt;code&gt;**********&lt;/code&gt; as a secret.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It deliberately leaves a short list of fields untouched even though some of their values are UUID-shaped: &lt;code&gt;clientId&lt;/code&gt;, &lt;code&gt;alias&lt;/code&gt;, &lt;code&gt;providerId&lt;/code&gt;, &lt;code&gt;authenticator&lt;/code&gt;, &lt;code&gt;protocolMapper&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;user.attribute&lt;/code&gt;, &lt;code&gt;claim.name&lt;/code&gt;, &lt;code&gt;user.session.note&lt;/code&gt;, and &lt;code&gt;serviceAccountClientId&lt;/code&gt;. These hold identifiers or configuration Keycloak matches by string value (like the &lt;code&gt;clientId&lt;/code&gt; &lt;code&gt;account&lt;/code&gt; or &lt;code&gt;admin-cli&lt;/code&gt;) — rewriting them would silently break built-in clients and mappers.&lt;/p&gt;

&lt;p&gt;For programmatic use, it's also a library:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;cloneRealm&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;keycloak-realm-clone&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;realm&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;summary&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;cloneRealm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;exportJson&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;newRealmName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;myrealm-dev&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="c1"&gt;// summary: { uuidsReplaced, secretsRemoved, oldRealmName, newRealmName }&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  After-import checklist
&lt;/h2&gt;

&lt;p&gt;The tool gets you a JSON file that imports without collisions, but a few things still need a human:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Import&lt;/strong&gt; the generated file via Admin Console → Add realm → Select file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Client secrets&lt;/strong&gt; — open the Credentials tab for each confidential client and copy the newly generated secret into whatever app consumes it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Identity provider / LDAP credentials&lt;/strong&gt; — re-enter the IdP secret or LDAP bind password; both were cleared for the same reason client secrets were.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SMTP and theme settings&lt;/strong&gt; — intentionally left untouched; review them for the new realm.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx keycloak-realm-clone your-realm-export.json myrealm-dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Source, issues, and the full README are at&lt;br&gt;
&lt;a href="https://github.com/VimukthiShohan/keycloak-realm-clone" rel="noopener noreferrer"&gt;github.com/VimukthiShohan/keycloak-realm-clone&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>keycloak</category>
      <category>sso</category>
      <category>devops</category>
      <category>node</category>
    </item>
  </channel>
</rss>
