<?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>Web on Erik Failing</title><link>https://erikfailing.com/tags/web/</link><description>Recent content in Web on Erik Failing</description><generator>Hugo</generator><language>en-us</language><lastBuildDate>Mon, 02 Nov 2026 11:12:17 -0600</lastBuildDate><atom:link href="https://erikfailing.com/tags/web/index.xml" rel="self" type="application/rss+xml"/><item><title>Securing a Static Site</title><link>https://erikfailing.com/posts/securing-this-site/</link><pubDate>Fri, 30 Oct 2026 21:10:00 -0500</pubDate><guid>https://erikfailing.com/posts/securing-this-site/</guid><description>The security changes I made to this site, ordered by what they actually stop, from email spoofing down to certificate mis-issuance.</description><content:encoded><![CDATA[<p>A static site on GitHub Pages <span class="accent accent-ward">doesn&rsquo;t have much to attack</span>: no logins, no forms, no cookies, no
database. But &ldquo;not much&rdquo; isn&rsquo;t nothing, so I went through the whole setup (the domain, the build
pipeline and the browser side) and fixed what was worth fixing. Below are the changes in severity
order, what attack each one stops, and the couple of things I decided weren&rsquo;t worth it.</p>
<table>
	<thead>
			<tr>
					<th>#</th>
					<th>Change</th>
					<th>Attack it stops</th>
					<th>Severity before</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>1</td>
					<td>SPF + DMARC records</td>
					<td>Anyone sending email &ldquo;from&rdquo; my domain</td>
					<td>Medium</td>
			</tr>
			<tr>
					<td>2</td>
					<td>Hardened build pipeline</td>
					<td>A tampered tool or compromised step changing the live site</td>
					<td>Medium (unlikely, high impact)</td>
			</tr>
			<tr>
					<td>3</td>
					<td>Content-Security-Policy</td>
					<td>Injected scripts running on my pages</td>
					<td>Low to Medium</td>
			</tr>
			<tr>
					<td>4</td>
					<td>HSTS</td>
					<td>First-visit interception on hostile Wi-Fi</td>
					<td>Low</td>
			</tr>
			<tr>
					<td>5</td>
					<td>CAA records</td>
					<td>A certificate for my domain issued by the wrong authority</td>
					<td>Low</td>
			</tr>
	</tbody>
</table>
<h2 id="email-spoofing-spf-and-dmarc">Email Spoofing: SPF And DMARC</h2>
<p>This was the biggest open gap, and honestly not one I expected going in. I don&rsquo;t send any email
from erikfailing.com, so I never thought about it, but that&rsquo;s exactly the problem. With no email
records at all, nothing told receiving mail servers that mail &ldquo;from&rdquo; my domain was fake. <span class="accent accent-point">Anyone
could send a message as <code>erik@erikfailing.com</code></span> to a recruiter or a colleague, and the receiving
server had no published policy saying to reject it.</p>
<p>For a domain that never sends mail, the fix is <span class="accent accent-ward">two DNS records</span> that say &ldquo;nothing is allowed to
send as me, and reject anything that tries&rdquo;:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">erikfailing.com         TXT  &#34;v=spf1 -all&#34;
</span></span><span class="line"><span class="cl">_dmarc.erikfailing.com  TXT  &#34;v=DMARC1; p=reject; sp=reject; adkim=s; aspf=s;&#34;
</span></span></code></pre></div><p>SPF with <code>-all</code> lists zero allowed senders, and DMARC with <code>p=reject</code> tells every receiver that
checks (Gmail, Outlook, Yahoo, etc&hellip;) to reject mail that fails. Since I don&rsquo;t send mail from
this domain, there&rsquo;s nothing for these records to break. Gmail already spam-folders a lot of
unauthenticated mail on its own, but that&rsquo;s Gmail&rsquo;s call; these records make it <span class="accent accent-ward">my explicit
policy everywhere</span>.</p>
<h2 id="the-build-pipeline">The Build Pipeline</h2>
<p>I think this is the most interesting one, because it&rsquo;s <span class="accent accent-point">the only path to something actually bad</span>.
Anyone who can change what the build produces controls the live site, and no browser header can
stop that, since they&rsquo;d just remove the header too. <span class="accent accent-iron">Supply chain attacks on CI</span> are real: in March
2025, the popular <code>tj-actions/changed-files</code> GitHub Action had its version tags repointed to
malicious code, and every workflow using it ran that code on its next build.</p>
<p>So I tightened the <span class="accent accent-flow">GitHub Actions</span> workflow in a few ways:</p>
<ul>
<li><strong>Removed tools I wasn&rsquo;t using.</strong> The workflow I started from (Hugo&rsquo;s official one) downloaded
Dart Sass on every build and put it on the PATH, without verifying it. My site doesn&rsquo;t use Sass
at all, so that was a free download of <span class="accent accent-crimson">attack surface</span>. Go and Node steps went too.</li>
<li><strong>Verified the Hugo download.</strong> The workflow now checks Hugo&rsquo;s tarball against a <span class="accent accent-arcane">SHA-256</span> hash
from the release&rsquo;s checksums file, written into the workflow itself. If the download ever
doesn&rsquo;t match, the build fails before Hugo is even unpacked:</li>
</ul>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="l">echo &#34;${HUGO_SHA256}  ${{ runner.temp }}/hugo_${HUGO_VERSION}_linux-amd64.tar.gz&#34; | sha256sum --check --strict</span><span class="w">
</span></span></span></code></pre></div><ul>
<li><strong><span class="accent accent-ward">Split permissions by job.</span></strong> Before, the whole workflow had the right to publish the site,
including the build job where Hugo and the theme&rsquo;s code run. Now the build job can only read the
repo, and only the small deploy job (which just runs GitHub&rsquo;s own deploy action) can publish.</li>
<li><strong>Don&rsquo;t leave the token lying around.</strong> <code>actions/checkout</code> keeps the job&rsquo;s GitHub token on disk
by default, <span class="accent accent-crimson">where every later step can read it</span>. Nothing after checkout needs it, so
<code>persist-credentials: false</code> removes it.</li>
<li><strong><span class="accent accent-flow">Dependabot</span> for updates.</strong> It opens a pull request when the actions or the theme have updates,
so nothing goes stale and nothing changes without me reviewing it.</li>
</ul>
<p>I didn&rsquo;t pin actions to <span class="accent accent-iron">commit SHAs</span>. All of them are GitHub&rsquo;s own, and pinning would mean an
update pull request for every minor release. That&rsquo;s a trade-off I&rsquo;m fine with for now.</p>
<h2 id="content-security-policy">Content-Security-Policy</h2>
<p>A <span class="accent accent-ward">CSP</span> tells the browser where scripts, styles, fonts and images are allowed to come from. Mine
only allows the site itself:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">default-src &#39;none&#39;; script-src &#39;self&#39;; style-src &#39;self&#39;; img-src &#39;self&#39; data:; font-src &#39;self&#39;;
</span></span><span class="line"><span class="cl">connect-src &#39;self&#39;; manifest-src &#39;self&#39;; base-uri &#39;self&#39;; form-action &#39;none&#39;
</span></span></code></pre></div><p>With no user input, there&rsquo;s no classic <span class="accent accent-toxic accent-grad">XSS</span> path here. What the CSP covers is content-level
injection: a bad embed pasted into a post, or a malicious contribution that adds a script tag.
Those get blocked, along with any inline script or third-party resource.</p>
<p>Two catches made this more work than a one-liner:</p>
<ul>
<li><strong>GitHub Pages can&rsquo;t send custom headers,</strong> so the CSP lives in a <code>&lt;meta&gt;</code> tag. That works for
most directives, but not <code>frame-ancestors</code> (<span class="accent accent-ghostly accent-grad">clickjacking</span> protection), and not reporting.</li>
<li><strong>No <code>'unsafe-inline'</code></strong> meant cleaning up the theme first. PaperMod had three inline scripts,
which I moved into one self-hosted JS file with a <span class="accent accent-arcane">subresource integrity</span> hash. Hugo&rsquo;s syntax
highlighting wrote colors as inline <code>style=&quot;&quot;</code> attributes (75 of them on one post), so I
switched it to CSS classes.</li>
</ul>
<p>To be honest about its limits: anyone who controls the build (point 2) can just edit the meta tag,
so <span class="accent accent-point">the CSP is defense in depth, not a wall</span>. I tested it by loading every page in headless Chrome
with zero violations, and with a positive control page to make sure violations were actually
being caught.</p>
<h2 id="hsts">HSTS</h2>
<p><span class="accent accent-ward">HSTS</span> tells browsers &ldquo;only ever use HTTPS for this domain.&rdquo; Without it, the very first request to
<code>http://erikfailing.com</code> (say, someone typing the address on hotel Wi-Fi) goes out unencrypted
before GitHub&rsquo;s redirect to HTTPS. An attacker on that network can answer it themselves with a
fake copy of the site, a <span class="accent accent-crimson">phishing</span> link, or a cryptominer. Hotspot and ISP script injection has
happened for real (Comcast&rsquo;s Wi-Fi hotspots injected ads into pages back in 2014). Browsers trying
HTTPS first by default shrinks this gap, but doesn&rsquo;t close it.</p>
<p>GitHub Pages can&rsquo;t send custom headers, and HSTS can&rsquo;t be set from a meta tag. My first support
ticket asking GitHub to enable it got <span class="accent accent-glitch accent-grad">auto-closed</span> because I was on the free plan, so I upgraded to
Pro and reopened it lol.</p>
<p><strong>Update (November 2, 2026):</strong> GitHub got back to me after I reopened the ticket and turned it on.
Every page now sends this header:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Strict-Transport-Security: max-age=31556952
</span></span></code></pre></div><p>That <code>max-age</code> is one year in seconds (31,556,952 / 86,400 = 365.2425 days, the average
Gregorian year). So once a browser has seen the site over HTTPS, it&rsquo;ll <span class="accent accent-ward">refuse plain HTTP for
erikfailing.com</span> for the next year, and the hotel Wi-Fi trick above stops working on repeat
visitors. It doesn&rsquo;t include <code>includeSubDomains</code> or <code>preload</code>, so the domain isn&rsquo;t on the browsers'
built-in preload list, which means the very first visit from a brand new browser still isn&rsquo;t
covered.</p>
<h2 id="caa-records">CAA Records</h2>
<p>Any of the hundreds of certificate authorities browsers trust can issue an HTTPS certificate for
any domain, as long as the requester passes that authority&rsquo;s check. <span class="accent accent-point">A CAA record limits it to the
ones I name</span>. GitHub Pages gets its certificates from <span class="accent accent-letsencrypt">Let&rsquo;s Encrypt</span>, so that&rsquo;s the only one:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">erikfailing.com  CAA  0 issue &#34;letsencrypt.org&#34;
</span></span><span class="line"><span class="cl">erikfailing.com  CAA  0 issuewild &#34;;&#34;
</span></span></code></pre></div><p>The second line blocks <span class="accent accent-crimson">wildcard certificates</span> entirely, since I don&rsquo;t need one. This stops someone
getting a valid certificate for my domain from a sloppier authority, which they could use to
impersonate the site to someone on their network. It&rsquo;s rare, but it&rsquo;s two DNS records. The one
thing to remember: if GitHub ever switches certificate authorities, renewals would fail until I
update this.</p>
<h2 id="what-i-didnt-bother-with">What I Didn&rsquo;t Bother With</h2>
<p>The scanner (<span class="accent accent-trophy">Mozilla HTTP Observatory</span>) took the site from a D (30) to a C (55), which is the
ceiling for meta tags on GitHub Pages, and then to a B (75) once GitHub turned on HSTS. Getting to
an A would mean putting <span class="accent accent-cloudflare">Cloudflare</span> in front of GitHub Pages or moving hosts, and I think that&rsquo;s
not worth it, because the remaining headers protect almost nothing on this site:</p>
<ul>
<li><strong>Clickjacking protection:</strong> there are no buttons that do anything sensitive to trick someone
into clicking.</li>
<li><strong><code>X-Content-Type-Options</code>:</strong> all content is mine, so there&rsquo;s nothing to sniff.</li>
<li><strong><code>Permissions-Policy</code>, COOP/CORP:</strong> no camera, mic, location, secrets, or third-party content
to protect.</li>
</ul>
<p>A few things were already in good shape too: <span class="accent accent-ward">DNSSEC</span> was on, GitHub&rsquo;s domain verification prevents
someone else claiming the domain on Pages, and there are no wildcard DNS records that could leave
a subdomain open to takeover.</p>
<p>So overall, I think the honest summary is that <span class="accent accent-point">the browser headers were the least important part
of this</span>. The real risks for a static site are the accounts and pipeline around it, and the email
records I didn&rsquo;t know were missing.</p>
]]></content:encoded></item><item><title>Building the Color Accents on This Site</title><link>https://erikfailing.com/posts/building-color-accents/</link><pubDate>Fri, 30 Oct 2026 19:30:00 -0500</pubDate><guid>https://erikfailing.com/posts/building-color-accents/</guid><description>Three versions of a text-coloring system, from automatic keyword matching to hand-placed accents, and what each one taught me.</description><content:encoded><![CDATA[<p>The colored words on this site went through three versions before they actually did their job.
I started with a <span class="accent accent-static accent-grad">fully automatic keyword matcher</span>, then made it per-page, and ended up
deleting most of it for something way simpler. This is the builder&rsquo;s side of that, including the
parts that didn&rsquo;t work.</p>
<h2 id="the-goal">The Goal</h2>
<p>I play a lot of ASCII and text-based games, and one of my favorite details in <span class="accent accent-qud accent-grad">Caves of Qud</span> is
how it colors certain words: an item name in a gradient, a faction in its own color. It makes a
wall of text easy to scan. I wanted the same thing here, with a few constraints:</p>
<ul>
<li><strong>Professional, not zany.</strong> It&rsquo;s a site people look at when deciding whether to hire me.</li>
<li><strong>Readable for everyone.</strong> Every color has to pass <span class="accent accent-clarity">WCAG AAA contrast</span> on the dark background,
and screen readers have to read the words normally.</li>
<li><strong>No inline styles.</strong> The site has a strict <span class="accent accent-ward">Content-Security-Policy</span> (<code>style-src 'self'</code>), so
every color has to come from a class in a stylesheet.</li>
</ul>
<h2 id="version-1-automatic-keyword-matching">Version 1: Automatic Keyword Matching</h2>
<p>The first design was a keyword table in a data file, applied automatically at build time. Add
&ldquo;fuzzing&rdquo; to the table, and every page that mentions fuzzing gets it colored. That&rsquo;s
<span class="accent accent-trophy">retroactive and zero effort per post</span>, which sounded great.</p>
<p>The hard part was that Hugo only gives you the rendered HTML, and I had to color text without ever
touching markup. A keyword inside a link, a code block, a heading or an <code>href</code> attribute must stay
alone. Go&rsquo;s regex engine has <span class="accent accent-glitch accent-grad">no lookbehind</span>, so you can&rsquo;t just write &ldquo;match this word unless
it&rsquo;s inside <code>&lt;code&gt;</code>.&rdquo; I ended up splitting each page into tokens (tags vs. plain text), tracking
which protected element I was inside, and only running replacements on plain text:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go-html-template" data-lang="go-html-template"><span class="line"><span class="cl"><span class="cp">{{-</span><span class="w"> </span><span class="k">range</span><span class="w"> </span><span class="nx">findRE</span><span class="w"> </span><span class="s">`&lt;!--[\s\S]*?--&gt;|&lt;[^&gt;]*&gt;|[^&lt;]+`</span><span class="w"> </span><span class="nx">$html</span><span class="w"> </span><span class="cp">-}}</span>
</span></span><span class="line"><span class="cl">  <span class="cp">{{-</span><span class="w"> </span><span class="k">if</span><span class="w"> </span><span class="nx">hasPrefix</span><span class="w"> </span><span class="na">.</span><span class="w"> </span><span class="s">&#34;&lt;&#34;</span><span class="w"> </span><span class="cp">-}}</span>  <span class="cm">{{/* a tag: update which protected element we&#39;re in */}}</span>
</span></span><span class="line"><span class="cl">  <span class="cp">{{-</span><span class="w"> </span><span class="k">else</span><span class="w"> </span><span class="k">if</span><span class="w"> </span><span class="k">eq</span><span class="w"> </span><span class="nx">$protTag</span><span class="w"> </span><span class="s">&#34;&#34;</span><span class="w"> </span><span class="cp">-}}</span>  <span class="cm">{{/* plain text outside code/links/headings: safe to edit */}}</span>
</span></span></code></pre></div><p>Matches got swapped for private-use placeholder characters first, so a short keyword couldn&rsquo;t
match inside a longer one that was already colored. Then there were the small surprises: Hugo&rsquo;s
typographer turns <code>'</code> into <code>&amp;rsquo;</code>, so &ldquo;Let&rsquo;s Encrypt&rdquo; never matched, and <code>&amp;</code> becomes <code>&amp;amp;</code>.</p>
<p>The gradients had their own constraint. Coloring each letter separately means one <code>&lt;span&gt;</code> per
letter, and current testing shows that <span class="accent accent-ghostly accent-grad">screen readers handle per-letter spans badly</span>
(spelling words out, or reading only the first letter). So every gradient is a single element
with <code>background-clip: text</code>, which paints the gradient inside the glyphs and leaves the text alone.</p>
<p><span class="accent accent-point">It worked, but it colored words, not ideas</span>. Pages about topics on the list got crowded, and
pages about anything else got nothing.</p>
<h2 id="version-2-per-page-picks">Version 2: Per-Page Picks</h2>
<p>The fix seemed obvious: let each page pick its own terms in front matter, with the shared table
kept as a library so &ldquo;fuzzing&rdquo; is the same color everywhere. I added build checks too: the build
<span class="accent accent-ward">fails on a typo&rsquo;d term</span> and warns when a picked term never gets colored.</p>
<p>Then the library grew to about 110 terms, including tools and languages in their brand colors
(Python&rsquo;s blue and yellow, Rust&rsquo;s copper, Claude&rsquo;s terracotta). A lot of brand colors are too dark
for a near-black page, so a script <span class="accent accent-arcane">lightened each one in OKLCH until it passed 7:1</span>. Raising
only the lightness keeps the hue and saturation, so reds stay red instead of fading to pink.</p>
<p>Two problems showed up once real posts used it:</p>
<ol>
<li><strong>Too many gradients.</strong> When everything shimmers, nothing stands out. I cut them to
<span class="accent accent-trophy">about 20% of styles</span> and made the rest solid colors.</li>
<li><strong>First mentions cluster.</strong> An intro paragraph previews the whole post, so it holds the first
mention of half the picked terms and lights up like a Christmas tree.</li>
</ol>
<p>I also shipped a bug here worth admitting: a Hugo pipe passes its value as the <em>last</em> argument, I
wrote it as if it were the first, and <span class="accent accent-glitch accent-grad">every match on the site silently disappeared</span>. A
coverage report I&rsquo;d written for checking density caught it before anything went live.</p>
<h2 id="version-3-just-mark-the-point">Version 3: Just Mark the Point</h2>
<p>Eventually I realized I was solving the wrong problem. A machine can match terms reliably, but it
can&rsquo;t tell which phrase in a paragraph is <em>the point</em>, and that&rsquo;s what I actually wanted readers to
catch. So I deleted the matcher, the library and the front matter picks, and replaced all of it
with <span class="accent accent-point">an inline marker you place by hand</span>:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-markdown" data-lang="markdown"><span class="line"><span class="cl">The {=flow: upload step=} {=glitch: skips anything starting with a dot=} by default.
</span></span><span class="line"><span class="cl">If the build fails, {=point: the live site stays on the last good version=}.
</span></span></code></pre></div><p>There are two tiers. Colored accents mark the tool, company or idea a paragraph is about, picked
from a palette where every style has a short note on when to use it. A key-point style (bright
italics, no color) marks the one takeaway of a section, at most once per section. The
guideline is about <span class="accent accent-clarity">one accent per paragraph</span>, on the phrase that carries the idea.</p>
<p>The syntax took a couple of tries. My first version reused link syntax, <code>[phrase](=style)</code>, with a
render hook, which worked great on the site and <span class="accent accent-crimson">looked like broken links on GitHub</span>. Double
brackets (<code>[[style: phrase]]</code>) never fired, because Markdown&rsquo;s link parser claims <code>[</code> first. What
worked was Hugo&rsquo;s passthrough extension, which hands anything between custom delimiters to a render
hook untouched:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-toml" data-lang="toml"><span class="line"><span class="cl"><span class="p">[</span><span class="nx">markup</span><span class="p">.</span><span class="nx">goldmark</span><span class="p">.</span><span class="nx">extensions</span><span class="p">.</span><span class="nx">passthrough</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">  <span class="nx">enable</span> <span class="p">=</span> <span class="kc">true</span>
</span></span><span class="line"><span class="cl">  <span class="p">[</span><span class="nx">markup</span><span class="p">.</span><span class="nx">goldmark</span><span class="p">.</span><span class="nx">extensions</span><span class="p">.</span><span class="nx">passthrough</span><span class="p">.</span><span class="nx">delimiters</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">    <span class="nx">inline</span> <span class="p">=</span> <span class="p">[[</span><span class="s1">&#39;{=&#39;</span><span class="p">,</span> <span class="s1">&#39;=}&#39;</span><span class="p">]]</span>
</span></span></code></pre></div><p>I skipped <code>::</code> and <code>&lt;&lt;</code> as delimiters because they show up in normal writing about code
(<code>std::vector</code>, heredocs). The render hook splits off the style name, <span class="accent accent-ward">fails the build on an
unknown style</span>, and runs the inner text back through Markdown so bold and code inside an accent
still work. After converting, I diffed every rendered accent and link against a saved baseline,
and the output was byte-for-byte identical.</p>
<h2 id="what-id-take-from-it">What I&rsquo;d Take From It</h2>
<p>I think the main lesson is to automate what&rsquo;s actually mechanical and leave judgment to people.
Contrast checks, style validation and &ldquo;is this markup well-formed&rdquo; are perfect for the build to
enforce. Picking what matters in a paragraph isn&rsquo;t, and version 1 and 2 were me spending a lot of
clever code avoiding that.</p>
<p>The final system is a palette file, a CSS template that generates one class per style, and two
small render hooks. <span class="accent accent-point">That&rsquo;s less template code than the first version&rsquo;s matcher alone</span>, and
it does the job better. I built it with Claude Code doing a lot of the implementation and testing, which made
it cheap to try (and throw away) the first two versions lol.</p>
]]></content:encoded></item><item><title>How I Built This Site</title><link>https://erikfailing.com/posts/how-i-built-this-site/</link><pubDate>Fri, 30 Oct 2026 17:45:00 -0500</pubDate><guid>https://erikfailing.com/posts/how-i-built-this-site/</guid><description>Hugo, PaperMod, GitHub Actions and a custom domain, with an LLM helping along the way.</description><content:encoded><![CDATA[<p>This site is a static <span class="accent accent-hugo">Hugo</span> site using the PaperMod theme, built and deployed by <span class="accent accent-flow">GitHub Actions</span> to
GitHub Pages, with my Squarespace domain pointed at it. Below is how the pieces fit together, the
design decisions I made along the way, and how I used an LLM to help (and where I had to catch its
mistakes).</p>
<h2 id="the-setup-in-one-picture">The Setup In One Picture</h2>
<p>The whole thing is pretty simple once you see the flow:</p>
<ol>
<li>I write Markdown and push to <code>main</code>.</li>
<li>A GitHub Actions workflow installs Hugo, builds the site, and uploads the result.</li>
<li>GitHub Pages serves it, and my domain points at GitHub Pages.</li>
</ol>
<p>There&rsquo;s no server for me to patch, no database, and no hosting bill. For a personal site I think
that&rsquo;s the right amount of infrastructure: <span class="accent accent-point">the attack surface is basically a folder of HTML files</span>.</p>
<h2 id="why-hugo-and-why-papermod">Why Hugo, And Why PaperMod</h2>
<p>I went with Hugo because it&rsquo;s a <span class="accent accent-iron">single binary</span>, it builds fast (this site currently builds in under
100 ms locally), and the output is plain static files. No Node toolchain to keep updated, which I
was honestly happy to skip.</p>
<p>For the theme, the first plan an LLM helped me brainstorm was actually <span class="accent accent-flood">Blowfish</span>. It looks great,
but when I compared them, PaperMod won for a couple of practical reasons:</p>
<ul>
<li><strong>Readability.</strong> PaperMod&rsquo;s article page is 18px text in a single ~720px column, with a lot less
visual chrome. People who read writeups are the main audience, so <span class="accent accent-point">the article page is what has
to be good</span>.</li>
<li><strong>Build compatibility.</strong> Blowfish needs Hugo&rsquo;s extended edition and capped its supported version
below what the official GitHub Pages workflow installs. PaperMod just works with standard Hugo.</li>
</ul>
<p>The theme is pulled in as a <span class="accent accent-git">Git submodule</span>, so the repo only stores a pointer to one specific
commit of PaperMod instead of a copy of its files:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">git submodule add --depth<span class="o">=</span><span class="m">1</span> https://github.com/adityatelange/hugo-PaperMod.git themes/PaperMod
</span></span></code></pre></div><p>Updating it is a deliberate step (<code>git submodule update --remote</code>, check locally, commit), so a
theme change <span class="accent accent-ward">never lands on the live site by surprise</span>. I also never edit the theme&rsquo;s files. Every
customization lives in my own <code>layouts/</code> and <code>assets/</code> folders, which Hugo uses in place of the
theme&rsquo;s versions.</p>
<h2 id="build-and-deploy-with-github-actions">Build And Deploy With GitHub Actions</h2>
<p>The workflow is <span class="accent accent-flow">Hugo&rsquo;s official GitHub Pages workflow</span>, with two small changes. I pulled it from
Hugo&rsquo;s docs repo directly and diffed my copy against it, so I know exactly what&rsquo;s different:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">env</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">HUGO_VERSION</span><span class="p">:</span><span class="w"> </span><span class="m">0.169.0</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">TZ</span><span class="p">:</span><span class="w"> </span><span class="l">America/Chicago            </span><span class="w"> </span><span class="c"># change 1: my time zone, for dates Hugo prints</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="c"># ...</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Upload artifact</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/upload-pages-artifact@v5</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">include-hidden-files</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">   </span><span class="c"># change 2: publish /.well-known/security.txt</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">path</span><span class="p">:</span><span class="w"> </span><span class="l">./public</span><span class="w">
</span></span></span></code></pre></div><p>The second one was a fun one to find. The upload step <span class="accent accent-glitch accent-grad">skips anything starting with a dot</span> by
default, which is a sensible default, but it means <code>/.well-known/</code> never gets published. More on
why I wanted that folder below.</p>
<p>A few things I like about this setup:</p>
<ul>
<li>The <span class="accent accent-flow">build job</span> only gets <code>contents: read</code>, <code>pages: write</code> and <code>id-token: write</code>. It can&rsquo;t push
to the repo.</li>
<li>If the build fails, nothing deploys, so <span class="accent accent-point">the live site stays on the last good version</span>.</li>
<li>The base URL comes from the repo&rsquo;s Pages settings at build time, so the same workflow worked at
<code>erikfailing.github.io</code> first and then at <code>erikfailing.com</code> <span class="accent accent-trophy">with zero changes</span>.</li>
</ul>
<h2 id="pointing-the-domain-at-it">Pointing The Domain At It</h2>
<p>My domain is registered through <span class="accent accent-iron">Squarespace</span>, so this was mostly clicking through their DNS panel
in the right order:</p>
<ol>
<li><strong><span class="accent accent-point">Verify the domain with GitHub first.</span></strong> GitHub gives you a TXT record
(<code>_github-pages-challenge-&lt;username&gt;</code>), and once it&rsquo;s verified, only my account can publish a
Pages site on that domain. This prevents <span class="accent accent-crimson">domain takeover</span> if the repo ever gets deleted or Pages
gets turned off while DNS still points at GitHub.</li>
<li><strong>Delete Squarespace&rsquo;s default records</strong> (their parking page A records and <code>www</code> CNAME).</li>
<li><strong>Add GitHub&rsquo;s records:</strong> four <code>A</code> records and four <code>AAAA</code> records for the apex, plus a <code>CNAME</code>
for <code>www</code> pointing to <code>erikfailing.github.io</code>.</li>
<li><strong>Set the custom domain in the repo and turn on <span class="accent accent-ward">Enforce HTTPS</span></strong> once GitHub finished issuing the
certificate.</li>
</ol>
<p>No <span class="accent accent-crimson">wildcard records</span>, since a wildcard is an easy way to end up with a subdomain someone else can
claim.</p>
<h2 id="a-few-security-flavored-touches">A Few Security-Flavored Touches</h2>
<p>It&rsquo;s a personal site, so I didn&rsquo;t go overboard, but there were a few things I wanted as a
security engineer:</p>
<ul>
<li><strong><code>security.txt</code>.</strong> There&rsquo;s an RFC 9116 file at <code>/.well-known/security.txt</code> that points to the
repo&rsquo;s <span class="accent accent-ward">private vulnerability reporting</span>, plus a short <code>SECURITY.md</code> policy. It has an <code>Expires</code>
field, so it&rsquo;s a once-a-year chore to bump the date.</li>
<li><strong>No third-party requests for fonts.</strong> I self-host the font instead of loading it from Google
Fonts, so loading a page <span class="accent accent-ward">doesn&rsquo;t send visitors&rsquo; IPs anywhere else</span>.</li>
<li><strong><code>robots.txt</code> that blocks <span class="accent accent-arcane">AI training but not AI search</span>.</strong> It turns out the big AI companies
use separate crawler names for training and for search (e.g. <code>GPTBot</code> vs <code>OAI-SearchBot</code>,
<code>ClaudeBot</code> vs <code>Claude-SearchBot</code>). So I asked the training crawlers to stay out while still
letting search engines and AI search assistants read and cite the site:</li>
</ul>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">User-agent: GPTBot
</span></span><span class="line"><span class="cl">User-agent: ClaudeBot
</span></span><span class="line"><span class="cl">User-agent: Google-Extended
</span></span><span class="line"><span class="cl">User-agent: Applebot-Extended
</span></span><span class="line"><span class="cl">User-agent: Meta-ExternalAgent
</span></span><span class="line"><span class="cl">User-agent: CCBot
</span></span><span class="line"><span class="cl">User-agent: Bytespider
</span></span><span class="line"><span class="cl">Disallow: /
</span></span></code></pre></div><p>(robots.txt is a request, not access control, but the reputable crawlers do honor it.)</p>
<p><strong>Update (October 31, 2026):</strong> I took these rules back out. They only stop crawlers polite enough
to ask, so they weren&rsquo;t keeping out anyone I&rsquo;d actually worry about, and I&rsquo;d rather AI assistants
know who I am when someone asks about me. The site also has an <code>llms.txt</code> now, a plain-text list
of its pages for AI tools.</p>
<h2 id="a-font-flicker-i-had-to-fix">A Font Flicker I Had To Fix</h2>
<p><span class="accent accent-clarity">Open Sans</span> is my favorite font, so the whole site uses it, self-hosted as one variable font file.
The first version had an annoying bug though: clicking between pages, the text would visibly
shift. It turned out to be a <span class="accent accent-static accent-grad">font swap on every page load</span>. The browser only discovered the font
after parsing the CSS, and <code>font-display: swap</code> told it to draw the text in the system font first
(Segoe UI on Windows) and then redraw it in Open Sans. The two fonts have different widths, so
everything jumped. <span class="accent accent-point">The fix was preloading the font</span>:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="cl"><span class="p">&lt;</span><span class="nt">link</span> <span class="na">rel</span><span class="o">=</span><span class="s">&#34;preload&#34;</span> <span class="na">href</span><span class="o">=</span><span class="s">&#34;/fonts/open-sans-latin-wght-normal.woff2&#34;</span> <span class="na">as</span><span class="o">=</span><span class="s">&#34;font&#34;</span> <span class="na">type</span><span class="o">=</span><span class="s">&#34;font/woff2&#34;</span> <span class="na">crossorigin</span><span class="p">&gt;</span>
</span></span></code></pre></div><p>plus switching to <code>font-display: block</code>, so the browser waits the few milliseconds for the
preloaded font instead of painting a stand-in.</p>
<p>The colors got a similar amount of care: every text color passes <span class="accent accent-clarity">WCAG AAA contrast</span> against the
background, and links stay underlined since my link blue is too close in brightness to the body
text for color alone to mark them.</p>
<h2 id="how-i-used-ai-on-this">How I Used AI On This</h2>
<p>I used <span class="accent accent-claude">Claude Code</span> (an LLM coding assistant) a lot on this project, and I think it was genuinely
useful, but <span class="accent accent-point">as an assistant, not as the one in charge</span>. It drafted config, looked up docs, ran test
builds, and did research I&rsquo;d otherwise do by hand (like verifying every crawler name against each
company&rsquo;s docs). I still made the decisions and read every change before it went in.</p>
<p>And reading everything mattered. A few examples:</p>
<ul>
<li>On the very first step, the submodule command it wrote had the wrong GitHub username for
PaperMod&rsquo;s author. That would have pulled a theme from a repo that wasn&rsquo;t the real one, which is
a <span class="accent accent-iron">supply-chain mistake in a single typo</span>. It got blocked before running, and the correct URL went
in instead, but it&rsquo;s exactly the kind of thing you only catch by actually reading the command.</li>
<li>The font flicker above was something I noticed clicking around the site myself. The first font
setup it gave me &ldquo;worked,&rdquo; it just wasn&rsquo;t right.</li>
</ul>
<p>So overall, <span class="accent accent-point">I think it saved me a lot of time on the tedious parts</span> (looking up DNS records,
reading theme source to find the right CSS selectors, etc&hellip;), and it left me more time for the
parts I actually care about. But anything it hands me still gets read before it ships.</p>
<h2 id="whats-next">What&rsquo;s Next</h2>
<p>No big roadmap. I&rsquo;ll update the site as I feel like it, mostly with <span class="accent accent-crimson">security projects</span> and
writeups, plus some software dev stuff here and there.</p>
]]></content:encoded></item></channel></rss>