<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Shell on Kinoko's TIL Log</title><link>https://kinoko-tech-blog-theta.vercel.app/tags/shell/</link><description>Recent content in Shell on Kinoko's TIL Log</description><generator>Hugo -- gohugo.io</generator><language>en</language><managingEditor>pippimotta@gmail.com (Kinoko)</managingEditor><webMaster>pippimotta@gmail.com (Kinoko)</webMaster><copyright>&amp;copy; Brewed by Kinoko</copyright><lastBuildDate>Sun, 14 Jun 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://kinoko-tech-blog-theta.vercel.app/tags/shell/index.xml" rel="self" type="application/rss+xml"/><item><title>ZDOTDIR Relocates Zsh Config and Runtime Out of HOME</title><link>https://kinoko-tech-blog-theta.vercel.app/posts/zdotdir-relocates-zsh-config-and-runtime-out-of-home/</link><pubDate>Sun, 14 Jun 2026 00:00:00 +0000</pubDate><author>pippimotta@gmail.com (Kinoko)</author><guid>https://kinoko-tech-blog-theta.vercel.app/posts/zdotdir-relocates-zsh-config-and-runtime-out-of-home/</guid><description>&lt;h2 id="the-point"&gt;The Point&lt;/h2&gt;
&lt;p&gt;After setting &lt;code&gt;ZDOTDIR&lt;/code&gt;, zsh reads all startup config files from that directory (instead of &lt;code&gt;$HOME&lt;/code&gt;) and writes runtime data (&lt;code&gt;.zsh_history&lt;/code&gt;, &lt;code&gt;.zsh_sessions&lt;/code&gt;) there too. This means &lt;code&gt;$HOME&lt;/code&gt; only needs a single &lt;code&gt;~/.zshenv&lt;/code&gt; as the bootstrap.&lt;/p&gt;
&lt;h2 id="explanation"&gt;Explanation&lt;/h2&gt;
&lt;p&gt;Zsh&amp;rsquo;s startup order always reads &lt;code&gt;/etc/zshenv&lt;/code&gt; first, then &lt;code&gt;$HOME/.zshenv&lt;/code&gt; &amp;ndash; this is the only file hardcoded to &lt;code&gt;$HOME&lt;/code&gt;. Once &lt;code&gt;~/.zshenv&lt;/code&gt; does &lt;code&gt;export ZDOTDIR=...&lt;/code&gt;, all subsequent files (&lt;code&gt;.zprofile&lt;/code&gt; / &lt;code&gt;.zshrc&lt;/code&gt; / &lt;code&gt;.zlogin&lt;/code&gt;) are looked up from &lt;code&gt;$ZDOTDIR&lt;/code&gt; instead. At the same time, &lt;code&gt;HISTFILE&lt;/code&gt; defaults and macOS&amp;rsquo;s shell session mechanism both use &lt;code&gt;${ZDOTDIR:-$HOME}&lt;/code&gt; as their base, so history and session restore data also land in ZDOTDIR automatically. The result is that &lt;code&gt;$HOME&lt;/code&gt; only has a symlink-level &lt;code&gt;.zshenv&lt;/code&gt;, while everything else can be centralized in &lt;code&gt;~/.config/zsh&lt;/code&gt; and managed through dotfiles.&lt;/p&gt;</description><content:encoded><![CDATA[<h2 id="the-point">The Point</h2>
<p>After setting <code>ZDOTDIR</code>, zsh reads all startup config files from that directory (instead of <code>$HOME</code>) and writes runtime data (<code>.zsh_history</code>, <code>.zsh_sessions</code>) there too. This means <code>$HOME</code> only needs a single <code>~/.zshenv</code> as the bootstrap.</p>
<h2 id="explanation">Explanation</h2>
<p>Zsh&rsquo;s startup order always reads <code>/etc/zshenv</code> first, then <code>$HOME/.zshenv</code> &ndash; this is the only file hardcoded to <code>$HOME</code>. Once <code>~/.zshenv</code> does <code>export ZDOTDIR=...</code>, all subsequent files (<code>.zprofile</code> / <code>.zshrc</code> / <code>.zlogin</code>) are looked up from <code>$ZDOTDIR</code> instead. At the same time, <code>HISTFILE</code> defaults and macOS&rsquo;s shell session mechanism both use <code>${ZDOTDIR:-$HOME}</code> as their base, so history and session restore data also land in ZDOTDIR automatically. The result is that <code>$HOME</code> only has a symlink-level <code>.zshenv</code>, while everything else can be centralized in <code>~/.config/zsh</code> and managed through dotfiles.</p>
<h2 id="knowledge-sugar">Knowledge Sugar</h2>
<ul>
<li>
<p><strong>Three categories of files to distinguish</strong>:</p>
<ul>
<li>Config files (<code>.zprofile</code>/<code>.zshrc</code>/conf.d) &ndash; go in dotfiles, version controlled.</li>
<li>Runtime data (<code>.zsh_history</code>, <code>.zsh_sessions/</code>) &ndash; machine-specific, may contain sensitive commands, <strong>always gitignore</strong>. Moving them to ZDOTDIR alongside config is just for tidiness, not for committing.</li>
<li>Bootstrap (<code>~/.zshenv</code>) &ndash; must stay in <code>$HOME</code>. Can itself be a symlink into your dotfiles.</li>
</ul>
</li>
<li>
<p><strong>HISTFILE does not automatically follow ZDOTDIR</strong>: <code>HISTFILE</code>&rsquo;s default is actually <code>~/.zsh_history</code> (bound to <code>$HOME</code>, not ZDOTDIR). To move it, you must explicitly set <code>HISTFILE=&quot;$ZDOTDIR/.zsh_history&quot;</code> in conf.d. <code>.zsh_sessions</code> is different &ndash; macOS&rsquo;s <code>/etc/zshrc_Apple_Terminal</code> uses <code>SHELL_SESSION_DIR=&quot;${ZDOTDIR:-$HOME}/.zsh_sessions&quot;</code>, so it follows ZDOTDIR automatically.</p>
</li>
<li>
<p><strong>Orphan file trap</strong>: files like <code>~/.zprofile</code> and <code>~/.zsh_history</code> generated <em>before</em> setting ZDOTDIR become &ldquo;dead files&rdquo; &ndash; still in <code>$HOME</code> but no longer read or written. A typical source is the Homebrew installer, which always appends <code>eval &quot;$(brew shellenv)&quot;</code> to <code>~/.zprofile</code>, but after ZDOTDIR takes effect that file is never sourced. Verify by comparing mtimes of <code>~/.zprofile</code> and <code>$ZDOTDIR/.zprofile</code>.</p>
</li>
<li>
<p><strong>Portability tip</strong>: instead of relying on the installer-generated <code>~/.zprofile</code> for Homebrew init, write a conditional prefix detection in conf.d:</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-zsh" data-lang="zsh"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">if</span> <span class="o">[[</span> -x /opt/homebrew/bin/brew <span class="o">]]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="nb">eval</span> <span class="s2">&#34;</span><span class="k">$(</span>/opt/homebrew/bin/brew shellenv<span class="k">)</span><span class="s2">&#34;</span>   <span class="c1"># Apple Silicon</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="k">elif</span> <span class="o">[[</span> -x /usr/local/bin/brew <span class="o">]]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="nb">eval</span> <span class="s2">&#34;</span><span class="k">$(</span>/usr/local/bin/brew shellenv<span class="k">)</span><span class="s2">&#34;</span>       <span class="c1"># Intel</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="k">fi</span></span></span></code></pre></div></li>
<li>
<p><strong>Wait, so does that mean&hellip;?</strong> Since <code>/etc/zshenv</code> runs before <code>~/.zshenv</code>, a system administrator could technically force-set ZDOTDIR in <code>/etc/zshenv</code>, overriding the user&rsquo;s choice. This is why security discussions often say <code>/etc/zshenv</code> is the zsh file that should be most trusted and audited.</p>
</li>
</ul>
]]></content:encoded></item></channel></rss>